From 023d0fcfce575030ee503c5f60df8c28dba7ab07 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sat, 2 May 2026 11:46:13 +0900
Subject: feat(decks): make deck CRUD work fully offline-first
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Create / Edit / Delete deck modals now write through localDeckRepository
and fire-and-forget syncActionAtom so the change is pushed when the
network is up. EditDeckModal reads its note-type list from the
local-first noteTypesAtom instead of fetching, and the "reconnect to..."
guards on the submit buttons are gone — the user can keep working while
offline.

Soft-delete intentionally does NOT cascade to notes/cards, matching the
server's existing deck.softDelete: the deck disappears from listings and
its children become unreachable that way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/client/components/EditDeckModal.tsx | 111 +++++++++++++-------------------
 1 file changed, 43 insertions(+), 68 deletions(-)

(limited to 'src/client/components/EditDeckModal.tsx')

diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx
index e9c2b7b..dc7ec11 100644
--- a/src/client/components/EditDeckModal.tsx
+++ b/src/client/components/EditDeckModal.tsx
@@ -1,7 +1,7 @@
-import { useAtomValue } from "jotai";
-import { type FormEvent, useCallback, useEffect, useState } from "react";
-import { ApiClientError, apiClient } from "../api";
-import { isOnlineAtom } from "../atoms";
+import { useAtomValue, useSetAtom } from "jotai";
+import { type FormEvent, useEffect, useState } from "react";
+import { noteTypesAtom, syncActionAtom } from "../atoms";
+import { localDeckRepository } from "../db/repositories";
 
 interface Deck {
 	id: string;
@@ -10,11 +10,6 @@ interface Deck {
 	defaultNoteTypeId: string | null;
 }
 
-interface NoteTypeSummary {
-	id: string;
-	name: string;
-}
-
 interface EditDeckModalProps {
 	isOpen: boolean;
 	deck: Deck | null;
@@ -22,54 +17,43 @@ interface EditDeckModalProps {
 	onDeckUpdated: () => void;
 }
 
-export function EditDeckModal({
-	isOpen,
+export function EditDeckModal(props: EditDeckModalProps) {
+	if (!props.isOpen || !props.deck) {
+		return null;
+	}
+	// Render the body only when actually open so the suspense-driven note types
+	// query does not fire on every host render (e.g. HomePage keeps the modal
+	// mounted at all times).
+	return <EditDeckModalContent {...props} deck={props.deck} />;
+}
+
+interface EditDeckModalContentProps extends EditDeckModalProps {
+	deck: Deck;
+}
+
+function EditDeckModalContent({
 	deck,
 	onClose,
 	onDeckUpdated,
-}: EditDeckModalProps) {
-	const [name, setName] = useState("");
-	const [description, setDescription] = useState("");
+}: EditDeckModalContentProps) {
+	const [name, setName] = useState(deck.name);
+	const [description, setDescription] = useState(deck.description ?? "");
 	const [defaultNoteTypeId, setDefaultNoteTypeId] = useState<string | null>(
-		null,
+		deck.defaultNoteTypeId,
 	);
-	const [noteTypes, setNoteTypes] = useState<NoteTypeSummary[]>([]);
-	const [isLoadingNoteTypes, setIsLoadingNoteTypes] = useState(false);
 	const [error, setError] = useState<string | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
-	const isOnline = useAtomValue(isOnlineAtom);
+	const noteTypesQuery = useAtomValue(noteTypesAtom);
+	const noteTypes = noteTypesQuery.data ?? [];
+	const triggerSync = useSetAtom(syncActionAtom);
 
-	const fetchNoteTypes = useCallback(async () => {
-		setIsLoadingNoteTypes(true);
-		try {
-			const res = await apiClient.rpc.api["note-types"].$get();
-			const data = await apiClient.handleResponse<{
-				noteTypes: NoteTypeSummary[];
-			}>(res);
-			setNoteTypes(data.noteTypes);
-		} catch {
-			// Non-critical: note type list is optional
-		} finally {
-			setIsLoadingNoteTypes(false);
-		}
-	}, []);
-
-	// Sync form state when deck changes
 	useEffect(() => {
-		if (deck) {
-			setName(deck.name);
-			setDescription(deck.description ?? "");
-			setDefaultNoteTypeId(deck.defaultNoteTypeId);
-			setError(null);
-		}
+		setName(deck.name);
+		setDescription(deck.description ?? "");
+		setDefaultNoteTypeId(deck.defaultNoteTypeId);
+		setError(null);
 	}, [deck]);
 
-	useEffect(() => {
-		if (isOpen) {
-			fetchNoteTypes();
-		}
-	}, [isOpen, fetchNoteTypes]);
-
 	const handleClose = () => {
 		setError(null);
 		onClose();
@@ -77,39 +61,31 @@ export function EditDeckModal({
 
 	const handleSubmit = async (e: FormEvent) => {
 		e.preventDefault();
-		if (!deck) return;
 
 		setError(null);
 		setIsSubmitting(true);
 
 		try {
-			const res = await apiClient.rpc.api.decks[":id"].$put({
-				param: { id: deck.id },
-				json: {
-					name: name.trim(),
-					description: description.trim() || null,
-					defaultNoteTypeId: defaultNoteTypeId || null,
-				},
+			const updated = await localDeckRepository.update(deck.id, {
+				name: name.trim(),
+				description: description.trim() || null,
+				defaultNoteTypeId: defaultNoteTypeId || null,
 			});
-			await apiClient.handleResponse(res);
+			if (!updated) {
+				setError("Deck not found.");
+				return;
+			}
 
 			onDeckUpdated();
 			onClose();
-		} catch (err) {
-			if (err instanceof ApiClientError) {
-				setError(err.message);
-			} else {
-				setError("Failed to update deck. Please try again.");
-			}
+			void triggerSync().catch(() => {});
+		} catch {
+			setError("Failed to update deck. Please try again.");
 		} finally {
 			setIsSubmitting(false);
 		}
 	};
 
-	if (!isOpen || !deck) {
-		return null;
-	}
-
 	return (
 		<div
 			role="dialog"
@@ -196,7 +172,7 @@ export function EditDeckModal({
 								id="edit-deck-default-note-type"
 								value={defaultNoteTypeId ?? ""}
 								onChange={(e) => setDefaultNoteTypeId(e.target.value || null)}
-								disabled={isSubmitting || isLoadingNoteTypes}
+								disabled={isSubmitting}
 								className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
 							>
 								<option value="">None</option>
@@ -219,8 +195,7 @@ export function EditDeckModal({
 							</button>
 							<button
 								type="submit"
-								disabled={isSubmitting || !name.trim() || !isOnline}
-								title={!isOnline ? "Reconnect to save changes" : undefined}
+								disabled={isSubmitting || !name.trim()}
 								className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
 							>
 								{isSubmitting ? "Saving..." : "Save Changes"}
-- 
cgit v1.3.1

