- 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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user