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>
This commit is contained in:
2026-06-23 14:19:59 +05:00
parent 1cde567dbe
commit 06b8d9f64b
14 changed files with 790 additions and 1 deletions
+92
View File
@@ -0,0 +1,92 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
const ARCHETYPE_RU: Record<string, string> = {
architect: "Архитектор",
gardener: "Садовник",
librarian: "Библиотекарь",
pragmatist: "Прагматик",
};
export async function generateMetadata({ params }: { params: Promise<{ token: string }> }): Promise<Metadata> {
const { token } = await params;
return {
title: "Моя карта знаний в Obsidian — Second Brain",
description: "Я собрал статистику своего хранилища знаний. Сделай свою карточку.",
openGraph: {
title: "Моя карта знаний в Obsidian",
description: "Statистика хранилища в Obsidian — Second Brain Wrapped.",
images: [{ url: `/share/wrapped/${token}/opengraph-image`, width: 1080, height: 1920 }],
},
twitter: { card: "summary_large_image" },
};
}
// Публичная страница карточки (без сессии). Показывает только агрегаты — никаких имён заметок.
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const run = await prisma.wrappedRun.findUnique({ where: { shareToken: token } });
if (!run) notFound();
const metric = (n: number, l: string) => (
<div
key={l}
style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "16px 12px", background: "#FFFFFF", border: "2px solid var(--border)", boxShadow: "4px 4px 0 0 var(--border)" }}
>
<span style={{ fontSize: 32, fontWeight: 700 }}>{n}</span>
<span style={{ fontSize: 12, color: "var(--muted-foreground)", textTransform: "uppercase", letterSpacing: 1 }}>{l}</span>
</div>
);
return (
<main style={{ background: "var(--background)", color: "var(--foreground)", minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", padding: "48px 24px" }}>
<div style={{ width: "100%", maxWidth: 480 }}>
<p style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: 3, color: "var(--muted-foreground)", marginBottom: 8 }}>
Obsidian Wrapped · 2026
</p>
<h1 style={{ fontSize: 28, fontWeight: 700, margin: "0 0 4px" }}>
{run.archetype ? ARCHETYPE_RU[run.archetype] : "Карта знаний"}
</h1>
<p style={{ fontSize: 13, textTransform: "uppercase", letterSpacing: 2, color: "var(--muted-foreground)", margin: "0 0 24px" }}>
зрелость {run.score}/100
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12, marginBottom: 20 }}>
{metric(run.noteCount, "заметок")}
{metric(run.linkCount, "связей")}
{metric(run.tagCount, "тегов")}
</div>
{run.topTag && (
<div style={{ padding: "16px 20px", background: "var(--accent)", border: "2px solid var(--foreground)", marginBottom: 24, fontSize: 16 }}>
Топ-тег: #{run.topTag}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Link
href="/register"
className="btn-aubade btn-aubade-accent"
style={{ textAlign: "center", fontSize: 15, fontWeight: 700, padding: "14px" }}
>
Сделать свою карточку
</Link>
<a
href={`/share/wrapped/${token}/opengraph-image`}
download={`obsidian-wrapped-${token}.png`}
className="btn-aubade"
style={{ textAlign: "center", fontSize: 14, padding: "12px" }}
>
Скачать картинку (1080×1920)
</a>
</div>
<p style={{ fontSize: 12, color: "var(--muted-foreground)", marginTop: 28, textAlign: "center" }}>
🔒 Здесь только числа. Содержимое заметок остаётся на устройстве автора.
</p>
</div>
</main>
);
}