Profile pictures, display name default, coloured service names
CI / check (push) Canceled after 0s

- Upload a profile picture on web and mobile. The API square-crops it to
  512px WebP, strips metadata (phone photos carry GPS), stores it in
  Postgres and serves it with immutable caching. Avatar URLs are stored as
  paths, so they survive moving the API. Arbitrary avatar URLs are no
  longer accepted (they'd let a profile track its viewers).
- New accounts get the username as typed as their display name; existing
  accounts are backfilled, and clearing it falls back to the username.
- Service names in Settings get a brand-coloured badge and larger type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-05 23:38:00 -04:00
co-authored by Claude Opus 5.5
parent 9873cc1710
commit 2b02aa8e89
25 changed files with 1746 additions and 22 deletions
+4
View File
@@ -66,6 +66,10 @@ input:focus, textarea:focus, select:focus {
.container { max-width: 680px; margin: 0 auto; padding: 1.75rem 1rem 5rem; }
.stack { display: flex; flex-direction: column; gap: 1rem; }
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.service-name { display: flex; align-items: center; gap: 0.6rem; font-family: var(--font-display); font-size: 1.12rem; font-weight: 700; margin-bottom: 0.2rem; }
.service-badge { width: 1.7rem; height: 1.7rem; border-radius: 0.5rem; display: grid; place-items: center; color: #fff; font-size: 0.95rem; border: 1px solid rgba(255, 255, 255, 0.25); flex-shrink: 0; }
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
.grow { flex: 1; min-width: 0; }
.muted { color: var(--muted); }
.error { color: var(--danger); margin: 0; }
+56 -7
View File
@@ -1,7 +1,7 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useState } from 'react';
import { Suspense, useRef, useState } from 'react';
import useSWR from 'swr';
import {
SERVICE_LABELS,
@@ -13,9 +13,11 @@ import {
type NowPlayingProvider,
type TrackSearchResultDTO,
} from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { ServiceName } from '@/components/ServiceName';
import { SharingControls } from '@/components/SharingControls';
import { SupportCard } from '@/components/SupportLink';
import { api, fetcher, useMe } from '@/lib/api';
import { api, fetcher, uploadFile, useMe } from '@/lib/api';
import { timeAgo } from '@musicshare/shared';
import { useDebounced } from '@/lib/useDebounced';
@@ -71,6 +73,7 @@ function ProfileForm({ me }: { me: MeDTO }) {
return (
<form className="card form" onSubmit={onSubmit}>
<h2>Profile</h2>
<AvatarPicker me={me} />
<label>
Display name
<input name="displayName" defaultValue={me.displayName ?? ''} maxLength={64} />
@@ -96,6 +99,52 @@ function ProfileForm({ me }: { me: MeDTO }) {
);
}
function AvatarPicker({ me }: { me: MeDTO }) {
const { mutate } = useMe();
const input = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async (fn: () => Promise<MeDTO>) => {
setBusy(true);
setError(null);
try {
await mutate(await fn(), { revalidate: false });
} catch (err) {
setError((err as Error).message);
}
setBusy(false);
};
const onPick = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ''; // picking the same file again should still upload
if (!file) return;
if (file.size > 10 * 1024 * 1024) return setError('That picture is too big (10 MB max)');
run(() => uploadFile<MeDTO>('/me/avatar', file));
};
return (
<div className="avatar-picker">
<Avatar user={me} size={72} />
<div className="avatar-picker-actions">
<div className="row">
<button type="button" className="btn" disabled={busy} onClick={() => input.current?.click()}>
{busy ? 'Uploading…' : me.avatarUrl ? 'Change picture' : 'Add a picture'}
</button>
{me.avatarUrl && (
<button type="button" className="btn btn-small" disabled={busy} onClick={() => run(() => api<MeDTO>('/me/avatar', { method: 'DELETE' }))}>
Remove
</button>
)}
</div>
{error && <p className="error">{error}</p>}
</div>
<input ref={input} type="file" accept="image/*" hidden onChange={onPick} />
</div>
);
}
function Connections() {
const params = useSearchParams();
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections', fetcher);
@@ -154,7 +203,7 @@ function Connections() {
{/* Spotify caps new apps at a handful of accounts; Spotify's own Last.fm/ListenBrainz link works for everyone. */}
<div className="connection spotify-how">
<div>
<strong>Spotify</strong>
<ServiceName service="spotify" />
<div className="muted">
Spotify sends everything you play to <strong>Last.fm</strong> or <strong>ListenBrainz</strong>, and we share it from
there. Connect one of them below, then link Spotify on their site:
@@ -181,7 +230,7 @@ function Connections() {
<div className="connection">
<div>
<strong>Plex</strong>
<ServiceName service="plex" />
<div>{status('plex') ?? <span className="muted">Music you play from your Plex servers</span>}</div>
<WebhookHint connection={byProvider.get('plex')} />
</div>
@@ -198,7 +247,7 @@ function Connections() {
<div className="connection">
<div className="grow">
<strong>Jellyfin</strong>
<ServiceName service="jellyfin" />
<div>{status('jellyfin') ?? <span className="muted">Sign in to your Jellyfin server. Your password isn&apos;t stored.</span>}</div>
<WebhookHint connection={byProvider.get('jellyfin')} />
{byProvider.get('jellyfin')?.status !== 'active' && (
@@ -230,7 +279,7 @@ function Connections() {
{(['lastfm', 'listenbrainz'] as const).map((p) => (
<div className="connection" key={p}>
<div>
<strong>{SERVICE_LABELS[p]}</strong>
<ServiceName service={p} />
<div>
{status(p) ?? (
<span className="muted">
@@ -267,7 +316,7 @@ function Connections() {
<div className={`connection ${byProvider.has('apple_music') ? '' : 'disabled'}`}>
<div>
<strong>Apple Music</strong>
<ServiceName service="apple_music" />
<div className="muted">{byProvider.has('apple_music') ? status('apple_music') : 'Connect from the musicshare iPhone app (one tap)'}</div>
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
import { SERVICE_COLORS, SERVICE_LABELS, type NowPlayingProvider, type StreamingService } from '@musicshare/shared';
/** A service's name with a badge in its brand colour, so it's easy to spot in a list. */
export function ServiceName({ service }: { service: StreamingService | NowPlayingProvider }) {
const label = SERVICE_LABELS[service];
return (
<span className="service-name">
<span className="service-badge" style={{ background: SERVICE_COLORS[service] }} aria-hidden>
{label.slice(0, 1).toUpperCase()}
</span>
{label}
</span>
);
}
+10
View File
@@ -26,6 +26,16 @@ export async function api<T = unknown>(path: string, init: { method?: string; bo
return data as T;
}
/** Uploads one file as multipart form data (e.g. the profile picture). */
export async function uploadFile<T = unknown>(path: string, file: File): Promise<T> {
const form = new FormData();
form.append('file', file);
const res = await fetch(API_URL + path, { method: 'POST', credentials: 'include', body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? res.statusText);
return data as T;
}
export const fetcher = <T,>(path: string) => api<T>(path);
export function useMe() {