Files
lms-sb/src/app/(student)/wrapped/WrappedClient.tsx
T
admins 06b8d9f64b Add Obsidian Wrapped feature (inside-LMS, client-side vault parsing)
- WrappedRun Prisma model + migration (aggregates only, no note names/text)
- /wrapped authed route: FSA + webkitdirectory + drag-drop reader, Web Worker parser
- archetype + score heuristics, ДС-2 result card
- POST /api/wrapped/run (userId from session), public /share/wrapped/[token]
- next/og OG image (Satori+resvg-wasm, alpine-safe) + Fira Mono ttf
- middleware: /share/wrapped public

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:19:59 +05:00

259 lines
10 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";
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 inputRef = useRef<HTMLInputElement>(null);
const notesRef = useRef<RawNote[]>([]);
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);
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<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 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>
{/* DOM-карточка результата (ДС-2) */}
<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(""); }} 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>
);
}