feat: save zine prompts to localStorage for draft recovery
- Auto-save topic, style, and tone as user types - Show "Saved Draft Found" banner on page load with restore option - Clear draft automatically when zine creation completes - Allows users to resume if they back out of the creation process 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9eff93d275
commit
b699b23082
|
|
@ -18,6 +18,8 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import TextSelectionCanvas from "@/components/zine/TextSelectionCanvas";
|
import TextSelectionCanvas from "@/components/zine/TextSelectionCanvas";
|
||||||
|
|
||||||
|
const DRAFT_STORAGE_KEY = "zineDraft";
|
||||||
|
|
||||||
// Helper to get correct path based on subdomain
|
// Helper to get correct path based on subdomain
|
||||||
function useZinePath() {
|
function useZinePath() {
|
||||||
const [isSubdomain, setIsSubdomain] = useState(false);
|
const [isSubdomain, setIsSubdomain] = useState(false);
|
||||||
|
|
@ -284,6 +286,13 @@ export default function CreatePage() {
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Clear the draft since zine is now complete
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
|
||||||
setState((s) =>
|
setState((s) =>
|
||||||
s ? { ...s, printLayoutUrl: data.printLayoutUrl, currentStep: "download" } : s
|
s ? { ...s, printLayoutUrl: data.printLayoutUrl, currentStep: "download" } : s
|
||||||
);
|
);
|
||||||
|
|
@ -858,6 +867,11 @@ export default function CreatePage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
sessionStorage.removeItem("zineInput");
|
sessionStorage.removeItem("zineInput");
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
router.push(getPath("/zine"));
|
router.push(getPath("/zine"));
|
||||||
}}
|
}}
|
||||||
className="text-gray-600 hover:text-black punk-text underline"
|
className="text-gray-600 hover:text-black punk-text underline"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Mic, MicOff, Sparkles, BookOpen, Printer, ArrowLeft } from "lucide-react";
|
import { Mic, MicOff, Sparkles, BookOpen, Printer, ArrowLeft, RotateCcw, X } from "lucide-react";
|
||||||
|
|
||||||
|
const DRAFT_STORAGE_KEY = "zineDraft";
|
||||||
|
|
||||||
// Helper to get correct path based on subdomain
|
// Helper to get correct path based on subdomain
|
||||||
function useZinePath() {
|
function useZinePath() {
|
||||||
|
|
@ -38,6 +40,13 @@ const TONES = [
|
||||||
{ value: "poetic", label: "Poetic", description: "Lyrical, metaphorical" },
|
{ value: "poetic", label: "Poetic", description: "Lyrical, metaphorical" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface ZineDraft {
|
||||||
|
topic: string;
|
||||||
|
style: string;
|
||||||
|
tone: string;
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ZinePage() {
|
export default function ZinePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const getPath = useZinePath();
|
const getPath = useZinePath();
|
||||||
|
|
@ -46,6 +55,61 @@ export default function ZinePage() {
|
||||||
const [tone, setTone] = useState("regenerative");
|
const [tone, setTone] = useState("regenerative");
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [savedDraft, setSavedDraft] = useState<ZineDraft | null>(null);
|
||||||
|
const [showDraftBanner, setShowDraftBanner] = useState(false);
|
||||||
|
|
||||||
|
// Load saved draft on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const draft: ZineDraft = JSON.parse(saved);
|
||||||
|
// Only show draft if it has a topic
|
||||||
|
if (draft.topic?.trim()) {
|
||||||
|
setSavedDraft(draft);
|
||||||
|
setShowDraftBanner(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-save draft when form changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (topic.trim()) {
|
||||||
|
const draft: ZineDraft = {
|
||||||
|
topic,
|
||||||
|
style,
|
||||||
|
tone,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [topic, style, tone]);
|
||||||
|
|
||||||
|
const restoreDraft = () => {
|
||||||
|
if (savedDraft) {
|
||||||
|
setTopic(savedDraft.topic);
|
||||||
|
setStyle(savedDraft.style);
|
||||||
|
setTone(savedDraft.tone);
|
||||||
|
setShowDraftBanner(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDraft = () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
setSavedDraft(null);
|
||||||
|
setShowDraftBanner(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleVoiceInput = () => {
|
const handleVoiceInput = () => {
|
||||||
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
|
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
|
||||||
|
|
@ -125,6 +189,42 @@ export default function ZinePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Draft Banner */}
|
||||||
|
{showDraftBanner && savedDraft && (
|
||||||
|
<div className="w-full max-w-2xl mb-6 punk-border bg-green-50 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<RotateCcw className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="font-bold punk-text text-sm">Saved Draft Found</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 truncate">
|
||||||
|
“{savedDraft.topic.length > 60 ? savedDraft.topic.slice(0, 60) + "..." : savedDraft.topic}”
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Saved {new Date(savedDraft.savedAt).toLocaleDateString()} at{" "}
|
||||||
|
{new Date(savedDraft.savedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={restoreDraft}
|
||||||
|
className="px-3 py-1.5 bg-green-600 text-white text-sm punk-text hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearDraft}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Discard draft"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Form */}
|
{/* Main Form */}
|
||||||
<form onSubmit={handleSubmit} className="w-full max-w-2xl space-y-6">
|
<form onSubmit={handleSubmit} className="w-full max-w-2xl space-y-6">
|
||||||
{/* Topic Input */}
|
{/* Topic Input */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue