From 3e5946875a3a790df28257671f8ab6aafb5be1cc Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 16:36:17 +0000 Subject: [PATCH 1/3] [#564] Auto-save draft content to localStorage with restore and discard - Add useDraft hook: debounced localStorage save (1s), restore on mount, beforeunload warning, clear/discard helpers - Create Storyline form: auto-saves title, content, genre, language (key: plotlink_draft_create) - Chain Plot form: auto-saves title, content (key: plotlink_draft_plot_{storylineId}) - "Draft restored" banner shown for 3s on page revisit - "Discard draft" button to clear saved content - Drafts cleared automatically on successful publish Fixes #564 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/create/page.tsx | 77 +++++++++++++++++++++++++++++++- src/hooks/useDraft.ts | 99 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useDraft.ts diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index e3adbac9..dfe2914a 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useState, useMemo, useEffect } from "react"; import { useAccount } from "wagmi"; import { useSearchParams } from "next/navigation"; +import { useDraft } from "../../hooks/useDraft"; import { useQuery } from "@tanstack/react-query"; import { validateContentLength, @@ -120,6 +121,26 @@ function CreatePage() { : false; const newBusy = newState !== "idle" && newState !== "error"; + // ---- New Storyline draft auto-save ---- + const newDraftValues = useMemo( + () => ({ title: newTitle, content: newContent, genre, language }), + [newTitle, newContent, genre, language], + ); + const newDraftSetters = useMemo( + () => ({ + title: setNewTitle, + content: setNewContent, + genre: setGenre, + language: setLanguage, + }), + [], + ); + const { + restored: newDraftRestored, + clearDraft: clearNewDraft, + discardDraft: discardNewDraft, + } = useDraft("plotlink_draft_create", newDraftValues, newDraftSetters); + // ---- Chain Plot state ---- const prefillStoryline = searchParams.get("storyline"); const [chainStorylineId, setChainStorylineId] = useState( @@ -161,6 +182,24 @@ function CreatePage() { chainValid; const chainBusy = chainState !== "idle" && chainState !== "error"; + // ---- Chain Plot draft auto-save ---- + const chainDraftKey = chainStorylineId + ? `plotlink_draft_plot_${chainStorylineId}` + : "plotlink_draft_plot"; + const chainDraftValues = useMemo( + () => ({ title: chainTitle, content: chainContent }), + [chainTitle, chainContent], + ); + const chainDraftSetters = useMemo( + () => ({ title: setChainTitle, content: setChainContent }), + [], + ); + const { + restored: chainDraftRestored, + clearDraft: clearChainDraft, + discardDraft: discardChainDraft, + } = useDraft(chainDraftKey, chainDraftValues, chainDraftSetters); + if (!isConnected) { return (
@@ -239,6 +278,12 @@ function CreatePage() { ); } + // Clear drafts on successful publish + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { if (newState === "published") clearNewDraft(); }, [newState]); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { if (chainState === "published") clearChainDraft(); }, [chainState]); + const noStoryline = chainStorylineId === null; return ( @@ -306,6 +351,21 @@ function CreatePage() { }} className="mt-6 space-y-6" > + {newDraftRestored && ( +
+ Draft restored + +
+ )} + {!newDraftRestored && (newTitle || newContent) && ( +
+ +
+ )}
+ {chainDraftRestored && ( +
+ Draft restored + +
+ )} + {!chainDraftRestored && (chainTitle || chainContent) && ( +
+ +
+ )}
{loadingStorylines ? ( diff --git a/src/hooks/useDraft.ts b/src/hooks/useDraft.ts new file mode 100644 index 00000000..bbcb305c --- /dev/null +++ b/src/hooks/useDraft.ts @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; + +const DEBOUNCE_MS = 1000; + +/** + * Auto-save and restore draft content from localStorage. + * Debounces writes by 1 second. Returns restore/discard helpers. + */ +export function useDraft>( + key: string, + currentValues: T, + setters: { [K in keyof T]: (val: T[K]) => void }, +) { + const [restored, setRestored] = useState(false); + const timerRef = useRef | null>(null); + const hasContent = useRef(false); + + // Restore on mount + useEffect(() => { + try { + const raw = localStorage.getItem(key); + if (!raw) return; + const saved = JSON.parse(raw) as Partial; + let didRestore = false; + for (const k of Object.keys(saved) as (keyof T)[]) { + if (saved[k] !== undefined && saved[k] !== "" && k in setters) { + (setters[k] as (val: unknown) => void)(saved[k]); + didRestore = true; + } + } + if (didRestore) setRestored(true); + } catch { + // Corrupt data — ignore + } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + // Auto-dismiss "Draft restored" after 3 seconds + useEffect(() => { + if (!restored) return; + const t = setTimeout(() => setRestored(false), 3000); + return () => clearTimeout(t); + }, [restored]); + + // Debounced save + useEffect(() => { + // Check if there's any non-empty content + const hasData = Object.values(currentValues).some( + (v) => typeof v === "string" ? v.length > 0 : v !== undefined && v !== null, + ); + hasContent.current = hasData; + + if (!hasData) { + localStorage.removeItem(key); + return; + } + + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + localStorage.setItem(key, JSON.stringify(currentValues)); + }, DEBOUNCE_MS); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [key, currentValues]); + + // beforeunload warning + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (hasContent.current) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, []); + + const clearDraft = useCallback(() => { + localStorage.removeItem(key); + hasContent.current = false; + }, [key]); + + const discardDraft = useCallback(() => { + localStorage.removeItem(key); + hasContent.current = false; + for (const k of Object.keys(setters) as (keyof T)[]) { + (setters[k] as (val: unknown) => void)( + typeof currentValues[k] === "string" ? "" : null, + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, setters]); + + return { restored, clearDraft, discardDraft }; +} From ede6172d992358b28fe191b6049261d8e926e5ea Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 16:38:21 +0000 Subject: [PATCH 2/3] [#564] Move useEffect calls above early return to fix Rules of Hooks useEffect for clearing drafts on publish must be called before the !isConnected early return to maintain consistent hook call order. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/create/page.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index dfe2914a..3cd7513c 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -200,6 +200,12 @@ function CreatePage() { discardDraft: discardChainDraft, } = useDraft(chainDraftKey, chainDraftValues, chainDraftSetters); + // Clear drafts on successful publish (must be above early returns — Rules of Hooks) + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { if (newState === "published") clearNewDraft(); }, [newState]); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { if (chainState === "published") clearChainDraft(); }, [chainState]); + if (!isConnected) { return (
@@ -278,12 +284,6 @@ function CreatePage() { ); } - // Clear drafts on successful publish - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { if (newState === "published") clearNewDraft(); }, [newState]); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { if (chainState === "published") clearChainDraft(); }, [chainState]); - const noStoryline = chainStorylineId === null; return ( From 15c6939fee190f76aefa570d320ed4b6ce66e4d7 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 16:41:03 +0000 Subject: [PATCH 3/3] [#564] Reset fields on key change when no draft exists for new key Prevents stale content from storyline A being saved under storyline B's draft key when switching storylines. Tracks previous key with a ref and resets bound fields if no draft exists for the new key. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useDraft.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/hooks/useDraft.ts b/src/hooks/useDraft.ts index bbcb305c..4ef44420 100644 --- a/src/hooks/useDraft.ts +++ b/src/hooks/useDraft.ts @@ -16,12 +16,22 @@ export function useDraft>( const [restored, setRestored] = useState(false); const timerRef = useRef | null>(null); const hasContent = useRef(false); + const prevKeyRef = useRef(key); - // Restore on mount + // Restore on mount or key change; reset fields if no draft for new key useEffect(() => { try { const raw = localStorage.getItem(key); - if (!raw) return; + if (!raw) { + // Key changed and no draft exists — reset fields to prevent stale saves + if (prevKeyRef.current !== key) { + for (const k of Object.keys(setters) as (keyof T)[]) { + (setters[k] as (val: unknown) => void)(""); + } + } + prevKeyRef.current = key; + return; + } const saved = JSON.parse(raw) as Partial; let didRestore = false; for (const k of Object.keys(saved) as (keyof T)[]) { @@ -34,7 +44,7 @@ export function useDraft>( } catch { // Corrupt data — ignore } - // Only run on mount + prevKeyRef.current = key; // eslint-disable-next-line react-hooks/exhaustive-deps }, [key]);