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
+14
View File
@@ -46,6 +46,17 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
return data as T;
}
/** Multipart upload (the browser/React Native sets the boundary itself). */
async function upload<T>(path: string, form: FormData): Promise<T> {
const headers: Record<string, string> = {};
const token = getToken?.();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(baseUrl + path, { method: 'POST', headers, credentials, body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? `Upload failed (${res.status})`);
return data as T;
}
const q = encodeURIComponent;
return {
@@ -60,6 +71,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
me: () => request<MeDTO>('/me'),
updateMe: (input: UpdateMeInput) => request<MeDTO>('/me', { method: 'PATCH', body: input }),
deleteAccount: () => request('/me', { method: 'DELETE' }),
/** `form` holds one file field (any name): a File on web, `{ uri, name, type }` on React Native. */
uploadAvatar: (form: FormData) => upload<MeDTO>('/me/avatar', form),
removeAvatar: () => request<MeDTO>('/me/avatar', { method: 'DELETE' }),
setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request<MeDTO>('/me/sharing', { method: 'PUT', body: { pause } }),
setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }),
clearFeatured: () => request('/me/featured', { method: 'DELETE' }),
-1
View File
@@ -24,7 +24,6 @@ export const loginSchema = z.object({
export const updateMeSchema = z.object({
displayName: z.string().trim().max(64).nullable().optional(),
bio: z.string().trim().max(280).nullable().optional(),
avatarUrl: z.url().max(500).nullable().optional(),
preferredService: z.enum(STREAMING_SERVICES).nullable().optional(),
/** Mark onboarding as finished. */
onboarded: z.literal(true).optional(),
+14
View File
@@ -33,6 +33,20 @@ export const SERVICE_LABELS: Record<StreamingService | NowPlayingProvider, strin
android_media_session: 'your phone',
};
/** Brand colours, for the badge shown next to a service's name. */
export const SERVICE_COLORS: Record<StreamingService | NowPlayingProvider, string> = {
spotify: '#1DB954',
apple_music: '#FA2D48',
youtube_music: '#FF0033',
deezer: '#A238FF',
tidal: '#000000',
lastfm: '#D51007',
listenbrainz: '#EB743B',
plex: '#E5A00D',
jellyfin: '#AA5CC3',
android_media_session: '#5b4cf0',
};
/** Fallback when we have no direct link: a search on that service. */
export function searchUrl(service: StreamingService, title: string, artist: string): string {
const q = encodeURIComponent(`${artist} ${title}`);