"use client"; import { useState, useRef, useCallback } from "react"; import type { Stats, RawNote, Period, RunPayload } from "./_client/types"; import { supportsFSA, readViaFSA, readViaInput, readViaDrop } from "./_client/reader"; type Screen = "intro" | "loading" | "result" | "error"; type View = "card" | "graph"; const ARCHETYPE_RU: Record = { architect: "🏛 Архитектор", gardener: "🌱 Садовник", librarian: "📚 Библиотекарь", pragmatist: "⚡ Прагматик", }; const PERIOD_RU: Record = { year: "год", quarter: "квартал", all: "всё время" }; export function WrappedClient({ userName }: { userName: string }) { const [screen, setScreen] = useState("intro"); const [stats, setStats] = useState(null); const [period, setPeriod] = useState("year"); const [error, setError] = useState(""); const [shareUrl, setShareUrl] = useState(""); const [sharing, setSharing] = useState(false); const [view, setView] = useState("card"); const [graphUrl, setGraphUrl] = useState(""); const [graphLabels, setGraphLabels] = useState(false); const [graphBusy, setGraphBusy] = useState(false); const inputRef = useRef(null); const notesRef = useRef([]); // Сбросить отрисованный граф (при новых данных / смене периода). const resetGraph = useCallback(() => { setView("card"); setGraphUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return ""; }); }, []); // Отрисовать граф-постер в офф-скрин Canvas → blob URL. force-graph грузим лениво. const renderGraph = useCallback( async (labels: boolean) => { if (!stats || !stats.graph.nodes.length) return; setGraphBusy(true); setError(""); try { const { renderGraphPoster } = await import("./_client/graphPoster"); const blob = await renderGraphPoster(stats.graph, labels); setGraphUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(blob); }); } catch { setError("Не удалось построить граф"); } setGraphBusy(false); }, [stats], ); const runWorker = useCallback((notes: RawNote[], p: Period) => { setScreen("loading"); const worker = new Worker(new URL("./_client/parse.worker.ts", import.meta.url), { type: "module" }); worker.onmessage = (e: MessageEvent) => { setStats(e.data); resetGraph(); setScreen("result"); worker.terminate(); }; worker.onerror = () => { setError("Не удалось обработать хранилище"); setScreen("error"); worker.terminate(); }; worker.postMessage({ notes, period: p }); }, [resetGraph]); const handleNotes = useCallback( (notes: RawNote[]) => { if (!notes.length) { setError("В выбранной папке не нашлось .md заметок"); setScreen("error"); return; } notesRef.current = notes; runWorker(notes, period); }, [period, runWorker], ); async function pickFSA() { try { handleNotes(await readViaFSA()); } catch (e) { if ((e as Error).name !== "AbortError") { setError("Не удалось прочитать папку"); setScreen("error"); } } } async function onInput(e: React.ChangeEvent) { if (e.target.files) handleNotes(await readViaInput(e.target.files)); } async function onDrop(e: React.DragEvent) { e.preventDefault(); if (e.dataTransfer.items) handleNotes(await readViaDrop(e.dataTransfer.items)); } function changePeriod(p: Period) { setPeriod(p); if (notesRef.current.length) runWorker(notesRef.current, p); } async function share() { if (!stats || sharing) return; setSharing(true); setError(""); const payload: RunPayload = { period: stats.period, noteCount: stats.noteCount, linkCount: stats.linkCount, tagCount: stats.tagCount, topTag: stats.topTags[0]?.tag ?? null, archetype: stats.archetype, score: stats.score, }; try { const res = await fetch("/api/wrapped/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); const data = (await res.json()) as { shareToken?: string }; if (data.shareToken) setShareUrl(`${location.origin}/share/wrapped/${data.shareToken}`); else setError("Не удалось создать ссылку"); } catch { setError("Не удалось создать ссылку"); } setSharing(false); } return (

Obsidian Wrapped · 2026

{screen === "intro" && (

Твой год в заметках, {userName.split(" ")[0]}

Выбери папку своего хранилища — всё считается прямо в браузере, заметки никуда не загружаются. На выходе — карточка «твой год в заметках», которой можно поделиться.

e.preventDefault()} className="p-8 text-center mb-4" style={{ border: "2px dashed var(--border)", borderRadius: 2 }} >

📂

{supportsFSA() ? ( ) : ( )}

или перетащи папку сюда

🔒 Приватность: тексты заметок остаются в браузере. На сервер уходят только числа (сколько заметок, связей, тегов).

)} {screen === "loading" && (

Считаю твой год…

)} {screen === "error" && (

{error}

)} {screen === "result" && stats && (
{/* переключатель периода */}
{(["year", "quarter", "all"] as Period[]).map((p) => ( ))} {/* переключатель вида: карточка / граф */}
{(["card", "graph"] as View[]).map((v) => ( ))}
{/* граф-постер хранилища */} {view === "graph" && (

Граф твоего хранилища

{stats.graph.nodes.length} самых связанных заметок из твоего хранилища. Имена остаются в браузере.

{graphBusy ? (

Строю граф…

) : graphUrl ? ( // eslint-disable-next-line @next/next/no-img-element Граф хранилища ) : stats.graph.nodes.length === 0 ? (

Слишком мало связей между заметками, чтобы построить граф.

) : null}
{graphUrl && ( Скачать граф (PNG) )}
)} {/* DOM-карточка результата (ДС-2) */} {view === "card" && (

Ты — {ARCHETYPE_RU[stats.archetype]}

зрелость {stats.score}/100 · период: {PERIOD_RU[stats.period]}

{[ { n: stats.noteCount, l: "заметок" }, { n: stats.linkCount, l: "связей" }, { n: stats.tagCount, l: "тегов" }, ].map((m) => (

{m.n}

{m.l}

))}
{stats.topTags.length > 0 && (

Топ-теги: {stats.topTags.map((t) => `#${t.tag}`).join(" ")}

)} {stats.achievements.length > 0 && (
{stats.achievements.map((a) => ( {a} ))}
)}
)} {/* действия */}
{shareUrl && ( )} {error &&

{error}

}
)}
); }