Inspired by a dashboard design the user shared. - App: new palette and fonts, borderless rounded panels with soft shadows, a pink-to-blue gradient card for your own song (text and controls switch to white on it), flat cards for friends, a 'Hi Chris, welcome back' header, an outline bell, and a pink pill on the active tab. - Website: the same look, plus a wide-screen layout with a sidebar, the page in a rounded panel, and a right column with your profile, recent activity and recent plays. Phones keep the top bar and bottom tabs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
import { Image } from 'expo-image';
|
|
import { useState } from 'react';
|
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
import useSWR from 'swr';
|
|
import type { ProfileDTO, TrackSearchResultDTO } from '@musicshare/shared';
|
|
import { api } from '@/lib/api';
|
|
import { showError } from '@/lib/errors';
|
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
|
import { useSession } from '@/lib/session';
|
|
import { useDebounced } from '@/lib/useDebounced';
|
|
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('');
|
|
const [saved, setSaved] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const query = useDebounced(q.trim(), 300);
|
|
const { data: results } = useSWR<TrackSearchResultDTO[]>(
|
|
query.length >= 2 && !selected ? `/tracks/search?q=${encodeURIComponent(query)}` : null,
|
|
);
|
|
|
|
const save = async () => {
|
|
if (!selected) return;
|
|
setBusy(true);
|
|
try {
|
|
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);
|
|
}
|
|
setBusy(false);
|
|
};
|
|
|
|
const body = (
|
|
<>
|
|
{selected ? (
|
|
<>
|
|
<View style={styles.result}>
|
|
{selected.artworkUrl && <Image source={selected.artworkUrl} style={styles.thumb} />}
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.title} numberOfLines={1}>
|
|
{selected.title}
|
|
</Text>
|
|
<Body muted style={styles.small}>
|
|
{selected.artist}
|
|
</Body>
|
|
</View>
|
|
<Button small title="Change" onPress={() => (setSelected(null), setSaved(false))} />
|
|
</View>
|
|
<Field
|
|
placeholder="Say something about it (optional)"
|
|
value={comment}
|
|
onChangeText={setComment}
|
|
maxLength={280}
|
|
multiline
|
|
style={{ minHeight: 64 }}
|
|
/>
|
|
<Button
|
|
variant="primary"
|
|
title={saved ? 'Featured ✓' : 'Feature this song'}
|
|
loading={busy}
|
|
onPress={save}
|
|
style={{ alignSelf: 'flex-start' }}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Field placeholder="Search for a song" value={q} onChangeText={setQ} autoCorrect={false} returnKeyType="search" />
|
|
{results?.map((r) => (
|
|
<Pressable key={r.deezerId} style={styles.result} onPress={() => setSelected(r)} accessibilityRole="button">
|
|
{r.artworkUrl && <Image source={r.artworkUrl} style={styles.thumb} />}
|
|
<Text style={styles.resultText} numberOfLines={1}>
|
|
{r.title} <Text style={{ color: colors.muted }}>· {r.artist}</Text>
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
small: { fontSize: 13, fontFamily: fonts.body },
|
|
title: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
|
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
|
resultText: { color: colors.text, flex: 1, fontSize: 14, fontFamily: fonts.body },
|
|
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
|
});
|