"use client"; /** * Folder library, stored next to the decks in localStorage. * * Folders are pure organisation: deleting one never deletes decks, it just * moves them back to "Unorganized". The virtual folder `UNORGANIZED_ID` is * never persisted — it is simply every deck with no `folderId`. */ import { t } from "./i18n"; import { folderSchema, UNORGANIZED_ID, type Folder } from "./schemas"; import { uid } from "./utils"; const STORAGE_KEY = "freezmo.folders.v1"; const EMPTY: Folder[] = []; type Listener = () => void; let folders: Folder[] | null = null; let cached: Folder[] = EMPTY; const listeners = new Set(); function read(): Folder[] { 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: Folder[] = []; for (const entry of parsed) { const result = folderSchema.safeParse(entry); if (result.success) valid.push(result.data); else console.warn("[freezmo] skipping invalid folder", result.error.issues); } return valid; } catch (error) { console.warn("[freezmo] could not read folders", error); return EMPTY; } } function ensureLoaded(): void { if (folders !== null) return; folders = read(); cached = folders; } function write(next: Folder[]): void { folders = next; cached = next; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch (error) { console.warn("[freezmo] could not save folders", error); } listeners.forEach((listener) => listener()); } export const folderStore = { subscribe(listener: Listener): () => void { ensureLoaded(); listeners.add(listener); const onStorage = (event: StorageEvent) => { if (event.key === STORAGE_KEY) { folders = null; ensureLoaded(); listeners.forEach((l) => l()); } }; window.addEventListener("storage", onStorage); return () => { listeners.delete(listener); window.removeEventListener("storage", onStorage); }; }, getSnapshot(): Folder[] { ensureLoaded(); return cached; }, getServerSnapshot(): Folder[] { return EMPTY; }, }; /* ------------------------------------------------------------------ */ /* Queries */ /* ------------------------------------------------------------------ */ export function listFolders(): Folder[] { return [...folderStore.getSnapshot()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), ); } export function getFolder(id: string | undefined): Folder | undefined { if (!id || id === UNORGANIZED_ID) return undefined; return folderStore.getSnapshot().find((folder) => folder.id === id); } export function folderName(id: string | undefined): string { if (!id || id === UNORGANIZED_ID) return t("common.unorganized"); return getFolder(id)?.name ?? t("common.unorganized"); } export function findFolderByName(name: string): Folder | undefined { const wanted = name.trim().toLowerCase(); return folderStore.getSnapshot().find((folder) => folder.name.trim().toLowerCase() === wanted); } /** Case-insensitive uniqueness; returns the existing folder when it matches. */ export function createFolder(name: string): { folder: Folder } | { error: string } { const trimmed = name.trim().replace(/\s+/g, " "); if (!trimmed) return { error: t("folder.nameRequired") }; if (trimmed.length > 60) return { error: t("folder.nameTooLong") }; const existing = findFolderByName(trimmed); if (existing) return { folder: existing }; const folder: Folder = { id: uid("fold"), name: trimmed, createdAt: Date.now() }; ensureLoaded(); write([...folderStore.getSnapshot(), folder]); return { folder }; } export function renameFolder(id: string, name: string): { ok: true } | { error: string } { const trimmed = name.trim().replace(/\s+/g, " "); if (!trimmed) return { error: t("folder.nameRequired") }; const clash = folderStore .getSnapshot() .find((folder) => folder.id !== id && folder.name.trim().toLowerCase() === trimmed.toLowerCase()); if (clash) return { error: t("folder.nameTaken") }; ensureLoaded(); write(folderStore.getSnapshot().map((folder) => (folder.id === id ? { ...folder, name: trimmed } : folder))); return { ok: true }; } /** Removes the folder. Decks inside it are handed back to the caller to re-home. */ export function removeFolder(id: string): void { ensureLoaded(); write(folderStore.getSnapshot().filter((folder) => folder.id !== id)); } /** Deleting a folder must never delete decks: callers pair this with unfileDecks(). */ export function hasFolder(id: string): boolean { return Boolean(getFolder(id)); } export function emptyFolder(): void { ensureLoaded(); write([]); }