f8aa27533c
- parse.worker collects edges, builds top-150-by-degree graph (links capped 600) - graphPoster.ts renders force-graph on off-screen canvas -> PNG (DS-2 colors), node names never leave the browser - WrappedClient: Карточка/Граф toggle, optional node labels, lazy-load force-graph - THIRD-PARTY-NOTICES for force-graph (MIT) and next/og satori/resvg (MPL-2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
"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<string, string> = {
|
|
architect: "🏛 Архитектор",
|
|
gardener: "🌱 Садовник",
|
|
librarian: "📚 Библиотекарь",
|
|
pragmatist: "⚡ Прагматик",
|
|
};
|
|
const PERIOD_RU: Record<Period, string> = { year: "год", quarter: "квартал", all: "всё время" };
|
|
|
|
export function WrappedClient({ userName }: { userName: string }) {
|
|
const [screen, setScreen] = useState<Screen>("intro");
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [period, setPeriod] = useState<Period>("year");
|
|
const [error, setError] = useState("");
|
|
const [shareUrl, setShareUrl] = useState("");
|
|
const [sharing, setSharing] = useState(false);
|
|
const [view, setView] = useState<View>("card");
|
|
const [graphUrl, setGraphUrl] = useState("");
|
|
const [graphLabels, setGraphLabels] = useState(false);
|
|
const [graphBusy, setGraphBusy] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const notesRef = useRef<RawNote[]>([]);
|
|
|
|
// Сбросить отрисованный граф (при новых данных / смене периода).
|
|
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<Stats>) => {
|
|
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<HTMLInputElement>) {
|
|
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 (
|
|
<main className="max-w-2xl mx-auto px-6 py-12 w-full">
|
|
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>
|
|
Obsidian Wrapped · 2026
|
|
</p>
|
|
|
|
{screen === "intro" && (
|
|
<div className="card-aubade p-8">
|
|
<h1 className="text-2xl font-bold mb-3">Твой год в заметках, {userName.split(" ")[0]}</h1>
|
|
<p className="text-sm mb-6" style={{ color: "var(--muted-foreground)" }}>
|
|
Выбери папку своего хранилища — всё считается прямо в браузере, заметки никуда не загружаются.
|
|
На выходе — карточка «твой год в заметках», которой можно поделиться.
|
|
</p>
|
|
<div
|
|
onDrop={onDrop}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
className="p-8 text-center mb-4"
|
|
style={{ border: "2px dashed var(--border)", borderRadius: 2 }}
|
|
>
|
|
<p className="text-3xl mb-2">📂</p>
|
|
{supportsFSA() ? (
|
|
<button onClick={pickFSA} className="btn-aubade btn-aubade-accent text-sm">
|
|
Выбрать папку хранилища
|
|
</button>
|
|
) : (
|
|
<button onClick={() => inputRef.current?.click()} className="btn-aubade btn-aubade-accent text-sm">
|
|
Выбрать папку хранилища
|
|
</button>
|
|
)}
|
|
<p className="text-xs mt-3" style={{ color: "var(--muted-foreground)" }}>
|
|
или перетащи папку сюда
|
|
</p>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
// @ts-expect-error webkitdirectory не в типах
|
|
webkitdirectory=""
|
|
directory=""
|
|
multiple
|
|
hidden
|
|
onChange={onInput}
|
|
/>
|
|
</div>
|
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
🔒 Приватность: тексты заметок остаются в браузере. На сервер уходят только числа (сколько заметок, связей, тегов).
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{screen === "loading" && (
|
|
<div className="card-aubade p-12 text-center">
|
|
<p className="text-3xl mb-3">⏳</p>
|
|
<p className="font-medium">Считаю твой год…</p>
|
|
</div>
|
|
)}
|
|
|
|
{screen === "error" && (
|
|
<div className="card-aubade p-8 text-center">
|
|
<p className="text-sm mb-4" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>
|
|
<button onClick={() => { setScreen("intro"); setError(""); }} className="btn-aubade text-sm">
|
|
Попробовать снова
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{screen === "result" && stats && (
|
|
<div className="space-y-4">
|
|
{/* переключатель периода */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{(["year", "quarter", "all"] as Period[]).map((p) => (
|
|
<button
|
|
key={p}
|
|
onClick={() => changePeriod(p)}
|
|
className="text-xs px-3 py-1.5"
|
|
style={{
|
|
border: "2px solid var(--foreground)",
|
|
borderRadius: 2,
|
|
background: period === p ? "var(--accent)" : "var(--background)",
|
|
fontWeight: period === p ? 700 : 400,
|
|
}}
|
|
>
|
|
{PERIOD_RU[p]}
|
|
</button>
|
|
))}
|
|
{/* переключатель вида: карточка / граф */}
|
|
<div className="flex gap-2 ml-auto">
|
|
{(["card", "graph"] as View[]).map((v) => (
|
|
<button
|
|
key={v}
|
|
onClick={() => {
|
|
setView(v);
|
|
if (v === "graph" && !graphUrl && !graphBusy) renderGraph(graphLabels);
|
|
}}
|
|
className="text-xs px-3 py-1.5"
|
|
style={{
|
|
border: "2px solid var(--foreground)",
|
|
borderRadius: 2,
|
|
background: view === v ? "var(--accent)" : "var(--background)",
|
|
fontWeight: view === v ? 700 : 400,
|
|
}}
|
|
>
|
|
{v === "card" ? "Карточка" : "Граф"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* граф-постер хранилища */}
|
|
{view === "graph" && (
|
|
<div className="card-aubade p-6 space-y-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h2 className="font-bold text-xl">Граф твоего хранилища</h2>
|
|
<label className="text-xs flex items-center gap-1.5" style={{ color: "var(--muted-foreground)" }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={graphLabels}
|
|
onChange={(e) => { setGraphLabels(e.target.checked); renderGraph(e.target.checked); }}
|
|
/>
|
|
подписи узлов
|
|
</label>
|
|
</div>
|
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
{stats.graph.nodes.length} самых связанных заметок из твоего хранилища. Имена остаются в браузере.
|
|
</p>
|
|
<div className="flex items-center justify-center" style={{ border: "2px solid var(--border)", borderRadius: 2, minHeight: 280, background: "#F5F5F0" }}>
|
|
{graphBusy ? (
|
|
<p className="text-sm py-12" style={{ color: "var(--muted-foreground)" }}>Строю граф…</p>
|
|
) : graphUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={graphUrl} alt="Граф хранилища" className="w-full h-auto" style={{ borderRadius: 2 }} />
|
|
) : stats.graph.nodes.length === 0 ? (
|
|
<p className="text-sm py-12 px-4 text-center" style={{ color: "var(--muted-foreground)" }}>
|
|
Слишком мало связей между заметками, чтобы построить граф.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{graphUrl && (
|
|
<a href={graphUrl} download="obsidian-graph.png" className="btn-aubade text-sm inline-block">
|
|
Скачать граф (PNG)
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* DOM-карточка результата (ДС-2) */}
|
|
{view === "card" && (
|
|
<div className="card-aubade p-6">
|
|
<h2 className="font-bold text-xl mb-1">Ты — {ARCHETYPE_RU[stats.archetype]}</h2>
|
|
<p className="text-xs uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
|
зрелость {stats.score}/100 · период: {PERIOD_RU[stats.period]}
|
|
</p>
|
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
|
{[
|
|
{ n: stats.noteCount, l: "заметок" },
|
|
{ n: stats.linkCount, l: "связей" },
|
|
{ n: stats.tagCount, l: "тегов" },
|
|
].map((m) => (
|
|
<div key={m.l} className="p-3 text-center" style={{ border: "2px solid var(--border)", boxShadow: "4px 4px 0 0 var(--border)" }}>
|
|
<p className="text-2xl font-bold">{m.n}</p>
|
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{m.l}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{stats.topTags.length > 0 && (
|
|
<p className="text-sm mb-2">
|
|
<span className="text-xs uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Топ-теги: </span>
|
|
{stats.topTags.map((t) => `#${t.tag}`).join(" ")}
|
|
</p>
|
|
)}
|
|
{stats.achievements.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mt-3">
|
|
{stats.achievements.map((a) => (
|
|
<span key={a} className="text-xs px-2 py-1" style={{ background: "var(--accent)", border: "2px solid var(--foreground)", borderRadius: 2 }}>
|
|
{a}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* действия */}
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<button onClick={share} disabled={sharing} className="btn-aubade btn-aubade-accent text-sm flex-1">
|
|
{sharing ? "Создаю ссылку…" : "Поделиться карточкой"}
|
|
</button>
|
|
<button onClick={() => { setScreen("intro"); setStats(null); setShareUrl(""); resetGraph(); }} className="btn-aubade text-sm flex-1">
|
|
Другое хранилище
|
|
</button>
|
|
</div>
|
|
|
|
{shareUrl && (
|
|
<div className="card-aubade p-4 space-y-3">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Публичная ссылка</p>
|
|
<a href={shareUrl} target="_blank" rel="noopener noreferrer" className="text-sm underline break-all" style={{ color: "var(--foreground)" }}>
|
|
{shareUrl}
|
|
</a>
|
|
</div>
|
|
<a href={`${shareUrl}/opengraph-image`} download className="btn-aubade text-sm inline-block">
|
|
Скачать картинку (PNG)
|
|
</a>
|
|
</div>
|
|
)}
|
|
{error && <p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|