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>
);
+37
View File
@@ -132,6 +132,38 @@ export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(
);
});
/** A settings row: label, optional value, chevron. `first` drops the divider above it. */
export function ListRow({
label,
value,
onPress,
danger,
first,
}: {
label: string;
value?: string | null;
onPress: () => void;
danger?: boolean;
first?: boolean;
}) {
return (
<Pressable
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [styles.listRow, !first && styles.listDivider, pressed && { backgroundColor: 'rgba(255,255,255,0.05)' }]}>
<Text style={[styles.listLabel, danger && { color: colors.danger }]}>{label}</Text>
{value ? (
<Text style={styles.listValue} numberOfLines={1}>
{value}
</Text>
) : (
<View style={{ flex: 1 }} />
)}
{!danger && <Text style={styles.listChevron}>›</Text>}
</Pressable>
);
}
export function Chip({ label, selected, onPress }: { label: string; selected?: boolean; onPress: () => void }) {
const press = usePressScale(0.92);
return (
@@ -190,6 +222,11 @@ const styles = StyleSheet.create({
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
buttonTextSmall: { fontSize: 13 },
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
listRow: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: 13, paddingHorizontal: 2, borderRadius: radius.sm },
listDivider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
listLabel: { color: colors.text, fontSize: 15, fontWeight: '600' },
listValue: { flex: 1, textAlign: 'right', color: colors.muted, fontSize: 14 },
listChevron: { color: colors.muted, fontSize: 20, lineHeight: 20 },
reveal: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center', paddingHorizontal: space.md },
revealText: { color: colors.accent2, fontWeight: '600', fontSize: 14 },
input: {