Unlock everything, add a donation link, credit Last.fm, nudge re-linking
- Every feature is free for everyone (the plan system stays for later); remaining limits are abuse guards - Optional website donation link (NEXT_PUBLIC_DONATION_URL). Not shown in the mobile apps, where tips to the developer generally need in-app purchase - Last.fm/ListenBrainz-sourced plays link to the user's profile, as Last.fm's API terms require - Connections track when they last saw activity; a scrobbler silent for over a week prompts re-linking Spotify (its link expires every 6 months) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -1 +1,3 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
# Optional donation link shown on the website (e.g. https://ko-fi.com/yourname)
|
||||
NEXT_PUBLIC_DONATION_URL=
|
||||
|
||||
@@ -18,7 +18,9 @@ COPY --from=prepare /app/tsconfig.base.json ./tsconfig.base.json
|
||||
# NEXT_PUBLIC_* values are inlined into the browser bundle at build time,
|
||||
# so the API URL is a build argument, not a runtime setting.
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
ARG NEXT_PUBLIC_DONATION_URL=
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||
NEXT_PUBLIC_DONATION_URL=$NEXT_PUBLIC_DONATION_URL \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npx turbo run build --filter=@musicshare/web
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); ou
|
||||
.nav-links a { text-decoration: none; color: var(--muted); }
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-links a.btn-primary { color: var(--accent-text); }
|
||||
.nav-links a.support-link { color: #ff7aa8; }
|
||||
@media (max-width: 480px) { .nav-links a.support-link { display: none; } }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -117,6 +119,7 @@ a.recent-row:hover .recent-title { color: var(--accent); }
|
||||
.recent-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.recent-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recent-title { font-weight: 600; }
|
||||
.credit { margin: 0.6rem 0 0; font-size: 0.8rem; }
|
||||
.recent-time { font-size: 0.8rem; white-space: nowrap; }
|
||||
.artwork-sm.artwork-empty { display: grid; place-items: center; background: var(--surface-2); color: var(--muted); }
|
||||
.picked { display: flex; gap: 0.75rem; align-items: center; }
|
||||
|
||||
@@ -5,13 +5,16 @@ import { Suspense, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
SERVICE_LABELS,
|
||||
SPOTIFY_LINK_PAGES,
|
||||
STREAMING_SERVICES,
|
||||
isQuietScrobbler,
|
||||
type ConnectionDTO,
|
||||
type MeDTO,
|
||||
type NowPlayingProvider,
|
||||
type TrackSearchResultDTO,
|
||||
} from '@musicshare/shared';
|
||||
import { SharingControls } from '@/components/SharingControls';
|
||||
import { SupportCard } from '@/components/SupportLink';
|
||||
import { api, fetcher, useMe } from '@/lib/api';
|
||||
import { timeAgo } from '@musicshare/shared';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
@@ -36,6 +39,7 @@ function Settings() {
|
||||
<SharingControls />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
<SupportCard />
|
||||
<DangerZone />
|
||||
</div>
|
||||
);
|
||||
@@ -189,6 +193,16 @@ function Connections() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connections.filter((c) => isQuietScrobbler(c)).map((c) => (
|
||||
<div className="notice" key={`quiet-${c.provider}`}>
|
||||
Nothing new from {SERVICE_LABELS[c.provider]} in over a week. If you listen on Spotify, its link to{' '}
|
||||
{SERVICE_LABELS[c.provider]} expires every 6 months.{' '}
|
||||
<a href={SPOTIFY_LINK_PAGES[c.provider as 'lastfm' | 'listenbrainz']} target="_blank" rel="noreferrer">
|
||||
Re-link Spotify
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
))}
|
||||
{spotifyLimited && (
|
||||
<div className="notice">
|
||||
<strong>Spotify sign-in is limited during beta.</strong> Spotify only lets a few accounts sign in to new apps
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function ProfilePage() {
|
||||
<p className="muted">Nothing playing right now.</p>
|
||||
)}
|
||||
|
||||
<RecentPlays plays={profile.recent} preferred={preferred} />
|
||||
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, useMe } from '@/lib/api';
|
||||
import { SupportLink } from './SupportLink';
|
||||
|
||||
export function Nav() {
|
||||
const { me, mutate } = useMe();
|
||||
@@ -18,6 +19,7 @@ export function Nav() {
|
||||
musicshare
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<SupportLink />
|
||||
{me ? (
|
||||
<>
|
||||
<Link href="/">Feed</Link>
|
||||
|
||||
@@ -31,7 +31,16 @@ export function NowPlayingCard({
|
||||
<span className="row">
|
||||
<span className={`status ${np.isPlaying ? 'status-live' : ''}`}>
|
||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||
<span className="muted"> via {np.sourceApp ?? SERVICE_LABELS[np.source]}</span>
|
||||
<span className="muted">
|
||||
{' via '}
|
||||
{np.sourceUrl ? (
|
||||
<a href={np.sourceUrl} target="_blank" rel="noreferrer">
|
||||
{np.sourceApp ?? SERVICE_LABELS[np.source]}
|
||||
</a>
|
||||
) : (
|
||||
(np.sourceApp ?? SERVICE_LABELS[np.source])
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{onTogglePin && (
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { SERVICE_LABELS, openUrl, timeAgo, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||
import { SERVICE_LABELS, openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||
|
||||
export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; preferred: StreamingService | null | undefined }) {
|
||||
export function RecentPlays({
|
||||
plays,
|
||||
preferred,
|
||||
attribution = {},
|
||||
}: {
|
||||
plays: RecentPlayDTO[];
|
||||
preferred: StreamingService | null | undefined;
|
||||
attribution?: ProfileDTO['attribution'];
|
||||
}) {
|
||||
const credits = (['lastfm', 'listenbrainz'] as const).filter((s) => attribution[s] && plays.some((p) => p.source === s));
|
||||
if (plays.length === 0) return null;
|
||||
return (
|
||||
<section className="card">
|
||||
@@ -39,6 +48,14 @@ export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; pref
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{credits.map((s) => (
|
||||
<p key={s} className="muted credit">
|
||||
Some plays via{' '}
|
||||
<a href={attribution[s]} target="_blank" rel="noreferrer">
|
||||
{s === 'lastfm' ? 'Last.fm' : 'ListenBrainz'}
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Donation link (Ko-fi, Buy Me a Coffee, GitHub Sponsors…). Web only: in the
|
||||
* iOS and Android apps, tips to the developer must go through in-app
|
||||
* purchase in most countries, so the apps don't show this.
|
||||
*/
|
||||
export const DONATION_URL = process.env.NEXT_PUBLIC_DONATION_URL || null;
|
||||
|
||||
export function SupportLink({ className = 'support-link' }: { className?: string }) {
|
||||
if (!DONATION_URL) return null;
|
||||
return (
|
||||
<a className={className} href={DONATION_URL} target="_blank" rel="noreferrer">
|
||||
♥ Support musicshare
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupportCard() {
|
||||
if (!DONATION_URL) return null;
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Support musicshare</h2>
|
||||
<p className="muted">
|
||||
musicshare is free, with every feature unlocked for everyone. If you enjoy it, a donation helps cover servers and
|
||||
keeps it that way.
|
||||
</p>
|
||||
<SupportLink className="btn btn-primary" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user