From 6529f09ae6d9e019661d888f9dd57db98b833384 Mon Sep 17 00:00:00 2001 From: Daniel Amaral Date: Mon, 11 May 2026 01:37:33 +0200 Subject: [PATCH 1/2] feat(deck-library): add updateName --- __tests__/deck-library.test.ts | 21 +++++++++++++++++++++ lib/deck-library.ts | 11 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/__tests__/deck-library.test.ts b/__tests__/deck-library.test.ts index 062bb03..2f841a1 100644 --- a/__tests__/deck-library.test.ts +++ b/__tests__/deck-library.test.ts @@ -101,6 +101,27 @@ describe('DeckLibrary.delete', () => { }); }); +describe('DeckLibrary.updateName', () => { + test('persists the new name and leaves other fields untouched', async () => { + const deck = sampleDeck(); + await deckLibrary.save(deck); + + await deckLibrary.updateName(deck.id, 'Summer Mix 2024'); + + const loaded = await deckLibrary.load(deck.id); + expect(loaded).not.toBeNull(); + expect(loaded!.name).toBe('Summer Mix 2024'); + expect(loaded!.id).toBe(deck.id); + expect(loaded!.sourceUrl).toBe(deck.sourceUrl); + expect(loaded!.createdAt).toBe(deck.createdAt); + expect(loaded!.cards).toEqual(deck.cards); + }); + + test('throws when the deck does not exist', async () => { + await expect(deckLibrary.updateName('missing', 'Anything')).rejects.toThrow(); + }); +}); + describe('DeckLibrary.updateYearOverride', () => { test('sets yearOverride only on the matching card; deck and other cards untouched', async () => { const deck = sampleDeck({ diff --git a/lib/deck-library.ts b/lib/deck-library.ts index 313f98a..2892f9e 100644 --- a/lib/deck-library.ts +++ b/lib/deck-library.ts @@ -7,6 +7,7 @@ export interface DeckLibrary { load(id: string): Promise; list(): Promise; delete(id: string): Promise; + updateName(id: string, name: string): Promise; updateYearOverride(deckId: string, isrc: string, year: number): Promise; } @@ -75,6 +76,16 @@ export const deckLibrary: DeckLibrary = { await AsyncStorage.removeItem(keyFor(id)); }); }, + updateName(id: string, name: string): Promise { + return withWriteLock(id, async () => { + const deck = await readDeck(id); + if (deck === null) { + throw new Error(`DeckLibrary.updateName: deck not found: ${id}`); + } + deck.name = name; + await AsyncStorage.setItem(keyFor(id), JSON.stringify(deck)); + }); + }, updateYearOverride(deckId: string, isrc: string, year: number): Promise { return withWriteLock(deckId, async () => { const deck = await readDeck(deckId); From 9d30ac34f6ed545937f96cecb3749894e775493f Mon Sep 17 00:00:00 2001 From: Daniel Amaral Date: Mon, 11 May 2026 01:39:22 +0200 Subject: [PATCH 2/2] feat(deck): header menu with rename and delete --- app/deck/[id].tsx | 290 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 281 insertions(+), 9 deletions(-) diff --git a/app/deck/[id].tsx b/app/deck/[id].tsx index 71c8aad..b0ee4db 100644 --- a/app/deck/[id].tsx +++ b/app/deck/[id].tsx @@ -3,11 +3,14 @@ import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, + Alert, BackHandler, FlatList, Keyboard, + Modal, Pressable, Text as RNText, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -136,6 +139,26 @@ export default function DeckDetailScreen() { [reload], ); + const onRename = useCallback( + async (deckId: string, name: string): Promise => { + await deckLibrary.updateName(deckId, name); + setState((current) => + current.kind === 'loaded' + ? { ...current, deck: { ...current.deck, name } } + : current, + ); + }, + [], + ); + + const onDelete = useCallback( + async (deckId: string): Promise => { + await deckLibrary.delete(deckId); + router.replace('/'); + }, + [router], + ); + return ( {state.kind === 'loading' && ( @@ -177,6 +200,8 @@ export default function DeckDetailScreen() { onCommitOverride={onCommitOverride} onOpenCard={(isrc) => router.push(`/card/${state.deck.id}/${isrc}`)} onExport={() => router.push(`/export/${state.deck.id}`)} + onRename={onRename} + onDelete={onDelete} /> )} @@ -191,6 +216,8 @@ type LoadedDeckProps = { onCommitOverride: (deckId: string, isrc: string, year: number) => Promise; onOpenCard: (isrc: string) => void; onExport: () => void; + onRename: (deckId: string, name: string) => Promise; + onDelete: (deckId: string) => Promise; }; type FilterKind = 'all' | 'kept' | 'dropped'; @@ -203,6 +230,8 @@ function LoadedDeck({ onCommitOverride, onOpenCard, onExport, + onRename, + onDelete, }: LoadedDeckProps): React.ReactElement { const rows = useMemo(() => buildRows(deck, drops), [deck, drops]); const keptCount = deck.cards.length; @@ -220,6 +249,28 @@ function LoadedDeck({ [rows, filter], ); + const [menuOpen, setMenuOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); + + const confirmDelete = useCallback(() => { + Alert.alert( + `Delete "${deck.name}"?`, + 'This is permanent. Year overrides for this deck will be lost.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + onDelete(deck.id).catch((err) => { + Alert.alert('Could not delete deck', String(err)); + }); + }, + }, + ], + ); + }, [deck.id, deck.name, onDelete]); + // The min-height: 0 flexbox fix referenced by #5: parent flex containers // must allow children to shrink below their content. On RN, putting a // FlatList directly inside a `flex-1` View works; the gotcha to avoid is @@ -257,19 +308,57 @@ function LoadedDeck({ ), headerRight: () => ( - } - > - Export PDF - + + } + > + Export PDF + + setMenuOpen(true)} + role="button" + accessibilityLabel="Deck actions" + hitSlop={8} + className="h-10 w-10 items-center justify-center rounded-2xl active:bg-navy700/50" + > + + + ), }} /> + setMenuOpen(false)} + onRename={() => { + setMenuOpen(false); + setRenameOpen(true); + }} + onDelete={() => { + setMenuOpen(false); + confirmDelete(); + }} + /> + + setRenameOpen(false)} + onCommit={async (name) => { + await onRename(deck.id, name); + setRenameOpen(false); + }} + /> + rowKey(row, idx)} @@ -451,3 +540,186 @@ function dropReasonCopy(reason: DroppedTrack['reason']): string { // future reasons can be remapped here without touching the row component. return reason; } + +type DeckActionsMenuProps = { + visible: boolean; + onClose: () => void; + onRename: () => void; + onDelete: () => void; +}; + +function DeckActionsMenu({ + visible, + onClose, + onRename, + onDelete, +}: DeckActionsMenuProps): React.ReactElement { + return ( + + + e.stopPropagation()} + className="mt-16 mr-3 rounded-2xl bg-navy800 border-[1.5px] border-navy600 overflow-hidden" + style={{ minWidth: 200 }} + > + + + + Rename deck + + + + + + + Delete deck + + + + + + ); +} + +type RenameDeckModalProps = { + visible: boolean; + initialName: string; + onClose: () => void; + onCommit: (name: string) => Promise; +}; + +function RenameDeckModal({ + visible, + initialName, + onClose, + onCommit, +}: RenameDeckModalProps): React.ReactElement { + const [text, setText] = useState(initialName); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (visible) { + setText(initialName); + setBusy(false); + } + }, [visible, initialName]); + + const trimmed = text.trim(); + const canCommit = trimmed.length > 0 && !busy; + + const submit = async (): Promise => { + if (!canCommit) return; + setBusy(true); + try { + await onCommit(trimmed); + } catch (err) { + Alert.alert('Could not rename deck', String(err)); + setBusy(false); + } + }; + + return ( + + + e.stopPropagation()} + className="w-full rounded-2xl bg-navy800 border-[1.5px] border-navy600 p-5 gap-4" + style={{ maxWidth: 420 }} + > + + Rename deck + + { + void submit(); + }} + autoFocus + editable={!busy} + selectTextOnFocus + returnKeyType="done" + accessibilityLabel="Deck name" + placeholder="Deck name" + placeholderTextColor="rgb(168 179 199 / 0.5)" + style={{ + color: tokens.colors.navy50, + fontFamily: 'Nunito_700Bold', + fontSize: 16, + paddingVertical: 10, + paddingHorizontal: 12, + }} + className="rounded-xl border-[1.5px] border-lime bg-navy900" + /> + + + Cancel + + { + void submit(); + }} + accessibilityLabel="Save name" + disabled={!canCommit} + > + Save + + + + + + ); +}