- 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:
@@ -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; }
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user