From 4f1df2c2041146b2d41b5b368385031de65a4371 Mon Sep 17 00:00:00 2001 From: William <248649537+stupidgiraffe@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:03:59 +0900 Subject: [PATCH] Add top-first card creation and drag ordering Prepend manual and AI-generated cards, expose a new-card-only AI mode, and add accessible drag-to-reorder controls with regression coverage. --- src/App.tsx | 111 ++++++++++++++------- src/__tests__/agent-utils.test.ts | 39 +++++++- src/__tests__/agent-ux-integration.test.ts | 8 ++ src/lib/agent-utils.ts | 19 +++- 4 files changed, 136 insertions(+), 41 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0736585..149254d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,11 @@ import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react' -import type { CSSProperties } from 'react' +import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' import { Plus, Printer, DownloadSimple, UploadSimple, Exam, Image as ImageIcon, Trash, ArrowLeft, DotsThreeVertical, Copy, Sparkle, Stack, CloudArrowUp, CloudArrowDown, LinkSimple, MagnifyingGlass, X, Gear, CheckCircle } from '@phosphor-icons/react' +import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core' +import type { DragEndEvent } from '@dnd-kit/core' +import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { Button } from '@/components/ui/button' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -36,6 +40,7 @@ import { CredentialBackupPanel } from '@/components/CredentialBackupPanel' import { DEFAULT_AGENT_DRAFT, acceptImageRisk, clearAgentDraft, clearStoredCredentials, hasAcceptedImageRisk, loadAgentDraft, persistAiKey, saveAgentDraft, setRememberAiKey, shouldRememberAiKey } from '@/lib/agent-preferences' import type { AgentDraftPreferences, CredentialBackupPayload, ImageSearchStyleId } from '@/lib/agent-preferences' import { IMAGE_SEARCH_STYLES, applyImageSearchStyle, getImageSearchTemplate, previewImageSearch } from '@/lib/image-search-styles' +import { mergeAgentResults, prependCard, reorderCardsById } from '@/lib/agent-utils' declare global { interface Window { @@ -864,6 +869,35 @@ function safeUrlHost(value: string): string { catch { return 'invalid base URL' } } +function SortableCardItem({ id, children }: { id: string; children: ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 20 : undefined, + } + + return ( +
+
+ +
+ {children} +
+ ) +} + function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, googleImageSearchCx, onOpenGoogleSettings }: SetEditorProps) { const [showTestDialog, setShowTestDialog] = useState(false) @@ -935,6 +969,10 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog const fileInputRefs = useRef>({}) const [previewContainerWidth, setPreviewContainerWidth] = useState(0) const previewObserverRef = useRef(null) + const cardSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) useEffect(() => { setRememberAiKey(rememberAiKeyOnDevice) @@ -1145,10 +1183,20 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog } updateSet(prev => ({ ...prev, - cards: [...prev.cards, newCard], + cards: prependCard(prev.cards, newCard), })) } + function reorderCards(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id) return + setRevisionApplyConflictSummary(null) + updateSet(prev => { + const cards = reorderCardsById(prev.cards, String(active.id), String(over.id)) + return cards === prev.cards ? prev : { ...prev, cards } + }) + } + function updateCard(id: string, updates: Partial) { setRevisionApplyConflictSummary(null) updateSet(prev => ({ @@ -2071,31 +2119,12 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog setImageAgentLog((prev) => [...prev, `✓ Generated ${generatedCards.length} card${generatedCards.length === 1 ? '' : 's'}${imageAgentGenerateImages ? ' — now searching for images...' : ' — text saved.'}`]) // Build the working copy from Phase 1 results - if (flashcardAgentMode === 'create') { - workingCards = [ - ...set.cards, - ...generatedCards.map((card) => ({ - id: card.id, - frontText: card.frontText, - backText: card.backText, - imagePosition: 'front' as const, - frontImageScale: 1, - backImageScale: 1, - frontImageOffsetX: 0, - frontImageOffsetY: 0, - backImageOffsetX: 0, - backImageOffsetY: 0, - imageScale: 1, - })), - ] - } else { - const byId = new Map(generatedCards.map((c) => [c.id, c])) - workingCards = set.cards.map((card) => { - const gen = byId.get(card.id) - if (!gen) return card - return { ...card, frontText: gen.frontText || card.frontText, backText: gen.backText || card.backText } - }) - } + workingCards = mergeAgentResults( + set.cards, + generatedCards, + [], + flashcardAgentMode === 'create' ? 'create' : 'enhance', + ) hasNewContent = true // Persist text-only cards immediately so the user sees progress @@ -2540,8 +2569,11 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog + + card.id)} strategy={verticalListSortingStrategy}> {set.cards.map((card, index) => ( - + +
+
))} +
+
)} @@ -3283,19 +3318,27 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog
- + setFlashcardAgentCount(event.target.value)} disabled={imageAgentLoading} /> -

Default 8. Max 60 per run (larger = slower).

+

+ {flashcardAgentMode === 'create' + ? 'New cards are inserted at the top. Existing cards and their content stay unchanged.' + : 'Default 8. Max 60 per run (larger = slower).'} +

@@ -3342,8 +3385,8 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog