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 (
+
+
+
+
+ Drag to reorder
+
+
+ {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) => (
-
+
+
@@ -2770,7 +2802,10 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog
+
))}
+
+
>
)}
@@ -3283,19 +3318,27 @@ function SetEditor({ set, onBack, onUpdate, onDuplicate, googleImageApiKey, goog
Agent mode
{
- if (!imageAgentLoading && !aiConnectionTestLoading) setFlashcardAgentMode(value as FlashcardAgentMode)
+ if (!imageAgentLoading && !aiConnectionTestLoading) {
+ const mode = value as FlashcardAgentMode
+ setFlashcardAgentMode(mode)
+ if (mode === 'create') setFlashcardAgentGenerateText(true)
+ }
}}>
Enhance current cards
- Create new cards
+ Add new cards only
-
Deck size / new cards
+
{flashcardAgentMode === 'create' ? 'New cards to add' : 'Cards to process'}
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
Options
- setFlashcardAgentGenerateText(event.target.checked)} />
- Let AI create/rewrite front and back text
+ setFlashcardAgentGenerateText(event.target.checked)} />
+ {flashcardAgentMode === 'create' ? 'Create text for new cards' : 'Let AI rewrite front and back text'}
setImageAgentGenerateImages(event.target.checked)} />
diff --git a/src/__tests__/agent-utils.test.ts b/src/__tests__/agent-utils.test.ts
index a548600..0045904 100644
--- a/src/__tests__/agent-utils.test.ts
+++ b/src/__tests__/agent-utils.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { mergeAgentResults } from '../lib/agent-utils'
+import { mergeAgentResults, prependCard, reorderCardsById } from '../lib/agent-utils'
import type { FlashCard } from '../lib/types'
// ---------------------------------------------------------------------------
@@ -20,12 +20,40 @@ function makeCard(overrides: Partial & { id: string; frontText: strin
}
}
+describe('manual card ordering', () => {
+ it('prepends a manually-created card without changing existing cards', () => {
+ const existing = [makeCard({ id: 'old-1', frontText: 'Old', backText: 'Back' })]
+ const newCard = makeCard({ id: 'new-1', frontText: '', backText: '' })
+ const result = prependCard(existing, newCard)
+
+ expect(result.map((card) => card.id)).toEqual(['new-1', 'old-1'])
+ expect(result[1]).toBe(existing[0])
+ })
+
+ it('moves cards by id without mutating the original order', () => {
+ const cards = [
+ makeCard({ id: 'c1', frontText: 'One', backText: '1' }),
+ makeCard({ id: 'c2', frontText: 'Two', backText: '2' }),
+ makeCard({ id: 'c3', frontText: 'Three', backText: '3' }),
+ ]
+ const result = reorderCardsById(cards, 'c3', 'c1')
+
+ expect(result.map((card) => card.id)).toEqual(['c3', 'c1', 'c2'])
+ expect(cards.map((card) => card.id)).toEqual(['c1', 'c2', 'c3'])
+ })
+
+ it('returns the same array when a drag target is missing', () => {
+ const cards = [makeCard({ id: 'c1', frontText: 'One', backText: '1' })]
+ expect(reorderCardsById(cards, 'c1', 'missing')).toBe(cards)
+ })
+})
+
// ---------------------------------------------------------------------------
// create mode
// ---------------------------------------------------------------------------
describe('mergeAgentResults – create mode', () => {
- it('appends generated cards to an empty deck', () => {
+ it('adds generated cards to an empty deck', () => {
const result = mergeAgentResults(
[],
[{ id: 'c1', frontText: 'Hello', backText: 'こんにちは' }],
@@ -77,15 +105,16 @@ describe('mergeAgentResults – create mode', () => {
expect(result[1].frontImageUrl).toBeUndefined()
})
- it('preserves existing cards alongside generated ones', () => {
+ it('prepends generated cards while preserving existing cards unchanged', () => {
const existing = [makeCard({ id: 'existing-1', frontText: 'Old', backText: 'Old back' })]
const generated = [{ id: 'new-1', frontText: 'New', backText: 'New back' }]
const result = mergeAgentResults(existing, generated, [], 'create')
expect(result).toHaveLength(2)
- expect(result[0].id).toBe('existing-1')
- expect(result[1].id).toBe('new-1')
+ expect(result[0].id).toBe('new-1')
+ expect(result[1]).toBe(existing[0])
+ expect(result[1]).toMatchObject({ id: 'existing-1', frontText: 'Old', backText: 'Old back' })
})
it('applies back-side image updates correctly', () => {
diff --git a/src/__tests__/agent-ux-integration.test.ts b/src/__tests__/agent-ux-integration.test.ts
index 922d599..925241a 100644
--- a/src/__tests__/agent-ux-integration.test.ts
+++ b/src/__tests__/agent-ux-integration.test.ts
@@ -24,4 +24,12 @@ describe('BYOK agent UX integration', () => {
expect(app).toContain(' {
+ expect(app).toContain('Add new cards only')
+ expect(app).toContain('Existing cards and their content stay unchanged.')
+ expect(app).toContain('prependCard(prev.cards, newCard)')
+ expect(app).toContain(' card.id === activeId)
+ const newIndex = cards.findIndex((card) => card.id === overId)
+ if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return cards
+ const reordered = [...cards]
+ const [moved] = reordered.splice(oldIndex, 1)
+ reordered.splice(newIndex, 0, moved)
+ return reordered
+}
+
/**
* Pure helper that merges agent-generated cards and image updates with the
* existing deck. Extracted from `runImageAgent` so it can be unit-tested
* without network access.
*
* Phase 1 — card generation:
- * create: appends `generatedCards` after `existingCards`
+ * create: prepends `generatedCards` before `existingCards` without
+ * modifying any existing card
* enhance: updates matching existing cards with new text
*
* Phase 2 — image application:
@@ -54,7 +69,7 @@ export function mergeAgentResults(
backImageOffsetY: 0,
imageScale: 1,
}))
- workingCards = [...existingCards, ...newCards]
+ workingCards = [...newCards, ...existingCards]
} else {
const byId = new Map(generatedCards.map((c) => [c.id, c]))
workingCards = existingCards.map((card) => {