"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"; 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 inputRef = useRef(null); const notesRef = useRef([]); 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); setScreen("result"); worker.terminate(); }; worker.onerror = () => { setError("Не удалось обработать хранилище"); setScreen("error"); worker.terminate(); }; worker.postMessage({ notes, period: p }); }, []); 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) => ( ))}
{/* DOM-карточка результата (ДС-2) */}

Ты — {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}

}
)}
); }