Tidy the header and Settings; remove a featured song
CI / check (push) Canceled after 0s

- Web header is just the logo and Feed / Profile / Settings (current page
  highlighted). On phones those move to a bottom tab bar. Log out, Admin
  and Support moved into Settings.
- The bottom of Settings (web and app) is now one Account list: email,
  password, blocked accounts, admin, support, privacy, terms, log out and
  delete, each a row that opens in place (web) or a screen (app).
- Featured song shows what's featured now, with Change and Remove.

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-06 14:15:14 -04:00
co-authored by Claude Opus 5.5
parent 75062d3644
commit 601398acd6
6 changed files with 499 additions and 244 deletions
+51 -2
View File
@@ -2,18 +2,24 @@ import { Image } from 'expo-image';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import type { TrackSearchResultDTO } from '@musicshare/shared';
import type { ProfileDTO, TrackSearchResultDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { colors, radius, space } from '@/lib/theme';
import { useSession } from '@/lib/session';
import { useDebounced } from '@/lib/useDebounced';
import { Body, Button, Card, Field, Heading } from './ui';
import { Body, Button, Card, Field, Heading, Row } from './ui';
/**
* Search for a song and pin it to your profile with an optional note.
* `bare` drops the card and heading (for onboarding, which has its own title).
*/
export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: () => void }) {
const { me } = useSession();
// In Settings, show what's featured now (with Change / Remove) before the search.
const { data: profile, mutate: refreshProfile } = useSWR<ProfileDTO>(!bare && me ? `/users/${me.username}` : null);
const current = profile?.featured ?? null;
const [editing, setEditing] = useState(false);
const [q, setQ] = useState('');
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(null);
const [comment, setComment] = useState('');
@@ -31,6 +37,12 @@ export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: ()
await api.setFeatured({ deezerId: selected.deezerId, comment: comment.trim() || null });
setSaved(true);
onSaved?.();
if (!bare) {
setEditing(false);
setSelected(null);
setComment('');
await refreshProfile();
}
} catch (err) {
showError(err);
}
@@ -86,12 +98,49 @@ export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: ()
);
if (bare) return <View style={{ gap: space.md }}>{body}</View>;
const remove = async () => {
setBusy(true);
try {
await api.clearFeatured();
setSaved(false);
await refreshProfile();
} catch (err) {
showError(err);
}
setBusy(false);
};
if (current && !editing) {
return (
<Card>
<Heading>Featured song</Heading>
<View style={styles.result}>
{current.track.artworkUrl && <Image source={current.track.artworkUrl} style={styles.thumb} />}
<View style={{ flex: 1 }}>
<Text style={styles.title} numberOfLines={1}>
{current.track.title}
</Text>
<Body muted style={styles.small}>
{current.track.artist}
</Body>
</View>
</View>
{current.comment ? <Body style={styles.small}>“{current.comment}”</Body> : null}
<Row>
<Button small title="Change" onPress={() => (setEditing(true), setComment(current.comment ?? ''))} />
<Button small title="Remove" loading={busy} onPress={remove} />
</Row>
</Card>
);
}
return (
<Card>
<Heading>Featured song</Heading>
<Body muted style={styles.small}>
Pin a song to your profile, with an optional note about why.
</Body>
{current && <Button small title={`Keep ${current.track.title}`} onPress={() => setEditing(false)} style={{ alignSelf: 'flex-start' }} />}
{body}
</Card>
);