From bf2b4c08362991558b2b3c6ba19593d721c85985 Mon Sep 17 00:00:00 2001 From: Daniel Amaral Date: Mon, 11 May 2026 01:15:52 +0200 Subject: [PATCH] refactor(deck): extract card-row atoms into components/deck/ --- app/review/[id].tsx | 221 +------------------------------- components/deck/KeptRow.tsx | 79 ++++++++++++ components/deck/YearDisplay.tsx | 59 +++++++++ components/deck/YearEditor.tsx | 96 ++++++++++++++ 4 files changed, 235 insertions(+), 220 deletions(-) create mode 100644 components/deck/KeptRow.tsx create mode 100644 components/deck/YearDisplay.tsx create mode 100644 components/deck/YearEditor.tsx diff --git a/app/review/[id].tsx b/app/review/[id].tsx index 160c856..6598e0d 100644 --- a/app/review/[id].tsx +++ b/app/review/[id].tsx @@ -6,11 +6,11 @@ import { FlatList, Pressable, Text as RNText, - TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { KeptRow } from '@/components/deck/KeptRow'; import { Dot } from '@/components/ui/dot'; import { InlineAlert } from '@/components/ui/inline-alert'; import { Pill } from '@/components/ui/pill'; @@ -20,7 +20,6 @@ import type { DroppedTrack } from '@/lib/deck-generator'; import { deckLibrary } from '@/lib/deck-library'; import { takePostGenerationDrops } from '@/lib/post-generation-store'; import type { Card, Deck } from '@/lib/types'; -import { cn } from '@/lib/utils'; import { tokens } from '@/theme/tokens'; type LoadState = @@ -32,9 +31,6 @@ type ReviewRow = | { kind: 'kept'; card: Card } | { kind: 'dropped'; track: DroppedTrack }; -const MIN_VALID_YEAR = 1900; -const MAX_VALID_YEAR = new Date().getFullYear(); - function rowKey(row: ReviewRow, index: number): string { if (row.kind === 'kept') return `k:${row.card.isrc}`; return `d:${index}:${row.track.artist}:${row.track.title}`; @@ -369,221 +365,6 @@ function LoadedReview({ ); } -type KeptRowProps = { - card: Card; - isEditing: boolean; - onStartEdit: () => void; - onCommit: (year: number) => Promise; - onOpenCard: () => void; -}; - -function KeptRow({ - card, - isEditing, - onStartEdit, - onCommit, - onOpenCard, -}: KeptRowProps): React.ReactElement { - const displayYear = card.yearOverride ?? card.year; - const hasOverride = card.yearOverride !== undefined; - - // Row-level tap opens the card preview. The year cell is a separate inner - // Pressable that intercepts the press so editing the year stays one tap - // away — the OVERRIDE / EDIT caption beneath the year is the affordance. - return ( - - - - - {card.title} - - - {card.artist} - - - {isEditing ? ( - - ) : ( - - )} - - - ); -} - -type YearDisplayProps = { - year: number | null; - hasOverride: boolean; - onPress: () => void; - artist: string; - title: string; -}; - -function YearDisplay({ - year, - hasOverride, - onPress, - artist, - title, -}: YearDisplayProps): React.ReactElement { - return ( - - - {year ?? '—'} - - - {hasOverride ? 'Override' : 'edit'} - - - ); -} - -type YearEditorProps = { - initialYear: number; - onCommit: (year: number) => Promise; -}; - -function YearEditor({ initialYear, onCommit }: YearEditorProps): React.ReactElement { - const [text, setText] = useState(String(initialYear)); - const [invalid, setInvalid] = useState(false); - const [busy, setBusy] = useState(false); - - const tryCommit = async (): Promise => { - const trimmed = text.trim(); - const parsed = Number.parseInt(trimmed, 10); - if ( - !/^\d{4}$/.test(trimmed) || - Number.isNaN(parsed) || - parsed < MIN_VALID_YEAR || - parsed > MAX_VALID_YEAR - ) { - setInvalid(true); - return; - } - setInvalid(false); - setBusy(true); - try { - await onCommit(parsed); - } finally { - setBusy(false); - } - }; - - return ( - - { - setText(next.replace(/\D/g, '').slice(0, 4)); - if (invalid) setInvalid(false); - }} - onSubmitEditing={() => { - void tryCommit(); - }} - keyboardType="number-pad" - inputMode="numeric" - maxLength={4} - autoFocus - editable={!busy} - selectTextOnFocus - accessibilityLabel="Year" - placeholder="YYYY" - placeholderTextColor="rgb(168 179 199 / 0.5)" - style={{ - color: tokens.colors.navy50, - fontFamily: 'Fraunces_900Black', - fontSize: 18, - textAlign: 'center', - width: 64, - paddingVertical: 6, - paddingHorizontal: 8, - }} - className={cn( - 'rounded-xl border-[1.5px] bg-navy900', - invalid ? 'border-red' : 'border-lime', - )} - /> - { - void tryCommit(); - }} - disabled={busy} - role="button" - accessibilityLabel="Save year" - hitSlop={8} - className="h-8 w-8 items-center justify-center rounded-xl bg-lime active:opacity-90" - style={{ - shadowColor: tokens.colors.limeD, - shadowOpacity: 1, - shadowRadius: 0, - shadowOffset: { width: 0, height: 4 }, - }} - > - - - - ); -} - function DroppedRow({ track }: { track: DroppedTrack }): React.ReactElement { return ( void; + onCommit: (year: number) => Promise; + onOpenCard: () => void; +}; + +export function KeptRow({ + card, + isEditing, + onStartEdit, + onCommit, + onOpenCard, +}: KeptRowProps): React.ReactElement { + const displayYear = card.yearOverride ?? card.year; + const hasOverride = card.yearOverride !== undefined; + + // Row-level tap opens the card preview. The year cell is a separate inner + // Pressable that intercepts the press so editing the year stays one tap + // away — the OVERRIDE / EDIT caption beneath the year is the affordance. + return ( + + + + + {card.title} + + + {card.artist} + + + {isEditing ? ( + + ) : ( + + )} + + + ); +} diff --git a/components/deck/YearDisplay.tsx b/components/deck/YearDisplay.tsx new file mode 100644 index 0000000..8d9bbcd --- /dev/null +++ b/components/deck/YearDisplay.tsx @@ -0,0 +1,59 @@ +import { Pressable } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +type YearDisplayProps = { + year: number | null; + hasOverride: boolean; + onPress: () => void; + artist: string; + title: string; +}; + +export function YearDisplay({ + year, + hasOverride, + onPress, + artist, + title, +}: YearDisplayProps): React.ReactElement { + return ( + + + {year ?? '—'} + + + {hasOverride ? 'Override' : 'edit'} + + + ); +} diff --git a/components/deck/YearEditor.tsx b/components/deck/YearEditor.tsx new file mode 100644 index 0000000..7f16707 --- /dev/null +++ b/components/deck/YearEditor.tsx @@ -0,0 +1,96 @@ +import { Ionicons } from '@expo/vector-icons'; +import { useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; + +import { cn } from '@/lib/utils'; +import { tokens } from '@/theme/tokens'; + +export const MIN_VALID_YEAR = 1900; +export const MAX_VALID_YEAR = new Date().getFullYear(); + +type YearEditorProps = { + initialYear: number; + onCommit: (year: number) => Promise; +}; + +export function YearEditor({ initialYear, onCommit }: YearEditorProps): React.ReactElement { + const [text, setText] = useState(String(initialYear)); + const [invalid, setInvalid] = useState(false); + const [busy, setBusy] = useState(false); + + const tryCommit = async (): Promise => { + const trimmed = text.trim(); + const parsed = Number.parseInt(trimmed, 10); + if ( + !/^\d{4}$/.test(trimmed) || + Number.isNaN(parsed) || + parsed < MIN_VALID_YEAR || + parsed > MAX_VALID_YEAR + ) { + setInvalid(true); + return; + } + setInvalid(false); + setBusy(true); + try { + await onCommit(parsed); + } finally { + setBusy(false); + } + }; + + return ( + + { + setText(next.replace(/\D/g, '').slice(0, 4)); + if (invalid) setInvalid(false); + }} + onSubmitEditing={() => { + void tryCommit(); + }} + keyboardType="number-pad" + inputMode="numeric" + maxLength={4} + autoFocus + editable={!busy} + selectTextOnFocus + accessibilityLabel="Year" + placeholder="YYYY" + placeholderTextColor="rgb(168 179 199 / 0.5)" + style={{ + color: tokens.colors.navy50, + fontFamily: 'Fraunces_900Black', + fontSize: 18, + textAlign: 'center', + width: 64, + paddingVertical: 6, + paddingHorizontal: 8, + }} + className={cn( + 'rounded-xl border-[1.5px] bg-navy900', + invalid ? 'border-red' : 'border-lime', + )} + /> + { + void tryCommit(); + }} + disabled={busy} + role="button" + accessibilityLabel="Save year" + hitSlop={8} + className="h-8 w-8 items-center justify-center rounded-xl bg-lime active:opacity-90" + style={{ + shadowColor: tokens.colors.limeD, + shadowOpacity: 1, + shadowRadius: 0, + shadowOffset: { width: 0, height: 4 }, + }} + > + + + + ); +}