"use client"; /** * Deck library backed by localStorage. No accounts, no server state. * Exposes a tiny observable store so every screen stays in sync. */ import { t } from "./i18n"; import { deckSchema, UNORGANIZED_ID, type Activity, type Deck } from "./schemas"; import { createFolder, findFolderByName, getFolder, removeFolder } from "./folders"; import { markSyncDirty } from "./sync-state"; import { uid } from "./utils"; const STORAGE_KEY = "freezmo.decks.v1"; const EMPTY: Deck[] = []; type Listener = () => void; let decks: Deck[] | null = null; let cached: Deck[] = EMPTY; const listeners = new Set(); function read(): Deck[] { if (typeof window === "undefined") return EMPTY; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return EMPTY; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return EMPTY; const valid: Deck[] = []; for (const entry of parsed) { const result = deckSchema.safeParse(entry); if (result.success) valid.push(result.data); else console.warn("[freezmo] skipping invalid deck in storage", result.error.issues); } return valid; } catch (error) { console.warn("[freezmo] could not read the deck library", error); return EMPTY; } } function ensureLoaded(): void { if (decks !== null) return; decks = read(); cached = decks; } function write(next: Deck[]): void { decks = next; cached = next; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch (error) { console.warn("[freezmo] could not save the deck library (storage full?)", error); } /* A deck change (add, edit, delete, import) with sync on pushes itself. */ if (markSyncDirty()) void import("./sync-auto").then((module) => module.scheduleAutoSync()); listeners.forEach((listener) => listener()); } /** Used by backup import and sync: wholesale replacement of the library. */ export function replaceDecks(next: Deck[]): void { const valid: Deck[] = []; for (const entry of next) { const result = deckSchema.safeParse(entry); if (result.success) valid.push(result.data); else console.warn("[freezmo] skipping invalid deck in import", result.error.issues); } write(valid); } export const deckStore = { subscribe(listener: Listener): () => void { ensureLoaded(); listeners.add(listener); // Another tab may have changed the library. const onStorage = (event: StorageEvent) => { if (event.key === STORAGE_KEY) { decks = null; ensureLoaded(); listeners.forEach((l) => l()); } }; window.addEventListener("storage", onStorage); return () => { listeners.delete(listener); window.removeEventListener("storage", onStorage); }; }, getSnapshot(): Deck[] { ensureLoaded(); return cached; }, getServerSnapshot(): Deck[] { return EMPTY; }, }; /** Newest first. */ export function sortDecks(list: Deck[]): Deck[] { return [...list].sort((a, b) => b.updatedAt - a.updatedAt); } export function getDeck(id: string): Deck | undefined { return deckStore.getSnapshot().find((deck) => deck.id === id); } export function upsertDeck(deck: Deck): void { ensureLoaded(); const current = deckStore.getSnapshot(); const index = current.findIndex((entry) => entry.id === deck.id); const next = index === -1 ? [deck, ...current] : current.map((entry) => (entry.id === deck.id ? deck : entry)); write(next); } export function renameDeck(id: string, title: string): void { ensureLoaded(); write(deckStore.getSnapshot().map((deck) => (deck.id === id ? { ...deck, title, updatedAt: Date.now() } : deck))); } export function deleteDeck(id: string): void { ensureLoaded(); write(deckStore.getSnapshot().filter((deck) => deck.id !== id)); } /** * Removes the stored notes text from a deck (the deck and its activities stay). * Used by "Delete notes text" — the notes live in the deck record because * Regenerate needs them; chat reads them from here too. */ export function deleteNotesText(id: string): void { ensureLoaded(); write( deckStore.getSnapshot().map((deck) => deck.id === id ? { ...deck, sourceText: "", updatedAt: Date.now() } : deck, ), ); } export function recordAttempt( id: string, result: { scorePercent: number; correct: number; total: number }, ): void { ensureLoaded(); write( deckStore.getSnapshot().map((deck) => deck.id === id ? { ...deck, attempts: deck.attempts + 1, bestScore: Math.max(deck.bestScore ?? 0, Math.round(result.scorePercent)), updatedAt: Date.now(), } : deck, ), ); } /** Move a deck into a folder (pass undefined/UNORGANIZED_ID for "Unorganized"). */ export function moveDeck(id: string, folderId: string | undefined): void { ensureLoaded(); const target = !folderId || folderId === UNORGANIZED_ID ? undefined : folderId; write( deckStore.getSnapshot().map((deck) => deck.id === id ? { ...deck, folderId: target, updatedAt: Date.now() } : deck, ), ); } /** Used when a folder is deleted: every deck inside goes back to Unorganized. */ export function unfileDecks(folderId: string): void { ensureLoaded(); write( deckStore.getSnapshot().map((deck) => deck.folderId === folderId ? { ...deck, folderId: undefined, updatedAt: Date.now() } : deck, ), ); } /** Deletes a folder for good — its decks always survive in "Unorganized". */ export function deleteFolder(folderId: string): void { unfileDecks(folderId); removeFolder(folderId); } export function countDecksInFolder(folderId: string): number { ensureLoaded(); if (folderId === UNORGANIZED_ID) return deckStore.getSnapshot().filter((deck) => !deck.folderId).length; return deckStore.getSnapshot().filter((deck) => deck.folderId === folderId).length; } export function appendActivities(id: string, activities: Activity[], title?: string): void { ensureLoaded(); write( deckStore.getSnapshot().map((deck) => deck.id === id ? { ...deck, title: title ?? deck.title, activities: [...deck.activities, ...activities], updatedAt: Date.now(), } : deck, ), ); } /* ------------------------------------------------------------------ */ /* Import / export */ /* ------------------------------------------------------------------ */ export type ExportBundle = { app: "freezmo"; version: 1; exportedAt: string; /** Folder name travels with the deck so imports land in the right place. */ folder?: string | null; deck: Deck; }; export function exportDeck(deck: Deck): string { const bundle: ExportBundle = { app: "freezmo", version: 1, exportedAt: new Date().toISOString(), folder: folderNameOf(deck), deck, }; return JSON.stringify(bundle, null, 2); } function folderNameOf(deck: Deck): string | null { if (!deck.folderId) return null; return getFolder(deck.folderId)?.name ?? null; } export function downloadDeck(deck: Deck): void { const blob = new Blob([exportDeck(deck)], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${deck.title.replace(/[^a-z0-9]+/gi, "-").toLowerCase() || "freezmo-deck"}.json`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); } /** Accepts a Freezmo export bundle or a bare deck object. */ export function importDeck(raw: string): { deck: Deck } | { error: string } { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { error: t("import.notJson") }; } const bundle = parsed && typeof parsed === "object" && "deck" in parsed ? (parsed as { folder?: unknown }) : null; const candidate = bundle ? (parsed as { deck: unknown }).deck : parsed; const result = deckSchema.safeParse(candidate); if (!result.success) { return { error: t("import.notDeck") }; } // Recreate the exported folder (or reuse an existing one with the same name). let folderId: string | undefined; const folderName = typeof bundle?.folder === "string" ? bundle.folder.trim() : ""; if (folderName) { const existing = findFolderByName(folderName); if (existing) { folderId = existing.id; } else { const created = createFolder(folderName); folderId = "folder" in created ? created.folder.id : undefined; } } const now = Date.now(); const deck: Deck = { ...result.data, folderId, id: uid("deck"), createdAt: now, updatedAt: now, attempts: 0, bestScore: undefined, }; upsertDeck(deck); return { deck }; }