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