Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion src/app/create/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<number | null>(
Expand Down Expand Up @@ -161,6 +182,30 @@ 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);

// 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 (
<div className="flex min-h-[calc(100vh-2.75rem)] flex-col items-center justify-center gap-4 px-6">
Expand Down Expand Up @@ -306,6 +351,21 @@ function CreatePage() {
}}
className="mt-6 space-y-6"
>
{newDraftRestored && (
<div className="border-accent/30 bg-accent/5 text-accent flex items-center justify-between rounded border px-3 py-2 text-xs">
<span>Draft restored</span>
<button type="button" onClick={discardNewDraft} className="text-muted hover:text-error ml-2 transition-colors">
Discard draft
</button>
</div>
)}
{!newDraftRestored && (newTitle || newContent) && (
<div className="flex justify-end">
<button type="button" onClick={discardNewDraft} className="text-muted hover:text-error text-[11px] transition-colors">
Discard draft
</button>
</div>
)}
<div>
<label className="text-foreground mb-2 block text-sm">Title</label>
<input
Expand Down Expand Up @@ -424,6 +484,21 @@ function CreatePage() {
}}
className="mt-6 space-y-6"
>
{chainDraftRestored && (
<div className="border-accent/30 bg-accent/5 text-accent flex items-center justify-between rounded border px-3 py-2 text-xs">
<span>Draft restored</span>
<button type="button" onClick={discardChainDraft} className="text-muted hover:text-error ml-2 transition-colors">
Discard draft
</button>
</div>
)}
{!chainDraftRestored && (chainTitle || chainContent) && (
<div className="flex justify-end">
<button type="button" onClick={discardChainDraft} className="text-muted hover:text-error text-[11px] transition-colors">
Discard draft
</button>
</div>
)}
<div>
<label className="text-foreground mb-2 block text-sm">Storyline</label>
{loadingStorylines ? (
Expand Down
109 changes: 109 additions & 0 deletions src/hooks/useDraft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"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<T extends Record<string, unknown>>(
key: string,
currentValues: T,
setters: { [K in keyof T]: (val: T[K]) => void },
) {
const [restored, setRestored] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasContent = useRef(false);
const prevKeyRef = useRef(key);

// Restore on mount or key change; reset fields if no draft for new key
useEffect(() => {
try {
const raw = localStorage.getItem(key);
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<T>;
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
}
prevKeyRef.current = key;
// 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 };
}
Loading