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
+98
View File
@@ -0,0 +1,98 @@
// Общий JSX карточки Obsidian Wrapped для next/og ImageResponse (1080×1920, ДС-2).
// Flexbox + inline-стили (Satori не поддерживает grid/внешний CSS). Без emoji
// (Satori их не рендерит без эмодзи-шрифта). Используется серверным OG-роутом.
import type { Archetype, Period } from "@/app/(student)/wrapped/_client/types";
export interface CardData {
noteCount: number;
linkCount: number;
tagCount: number;
topTag: string | null;
archetype: Archetype | null;
score: number;
period: Period;
}
const ARCHETYPE_RU: Record<string, string> = {
architect: "Архитектор",
gardener: "Садовник",
librarian: "Библиотекарь",
pragmatist: "Прагматик",
};
const PERIOD_RU: Record<string, string> = { year: "год", quarter: "квартал", all: "всё время" };
const BG = "#F5F5F0";
const FG = "#323232";
const ACCENT = "#E8F0D8";
const BORDER = "#AAAAAA";
const MUTED = "#666666";
function Metric({ n, l }: { n: number; l: string }) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "30px 18px",
background: "#FFFFFF",
border: `3px solid ${FG}`,
boxShadow: `8px 8px 0 0 ${BORDER}`,
width: 285,
}}
>
<span style={{ fontSize: 70, fontWeight: 700, color: FG }}>{n}</span>
<span style={{ fontSize: 24, color: MUTED, textTransform: "uppercase", letterSpacing: 2 }}>{l}</span>
</div>
);
}
export function wrappedCard(d: CardData) {
return (
<div
style={{
width: 1080,
height: 1920,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
background: BG,
color: FG,
padding: "90px 70px",
fontFamily: "Fira Mono",
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 34, fontWeight: 700, textTransform: "uppercase", letterSpacing: 6, color: MUTED }}>
Obsidian Wrapped · 2026
</span>
<span style={{ fontSize: 96, fontWeight: 700, marginTop: 36 }}>
{d.archetype ? ARCHETYPE_RU[d.archetype] : "Твой год"}
</span>
<span style={{ fontSize: 42, color: MUTED, marginTop: 14, textTransform: "uppercase", letterSpacing: 3 }}>
зрелость {d.score}/100 · {PERIOD_RU[d.period] ?? "год"}
</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Metric n={d.noteCount} l="заметок" />
<Metric n={d.linkCount} l="связей" />
<Metric n={d.tagCount} l="тегов" />
</div>
{d.topTag ? (
<div style={{ display: "flex", padding: "30px 36px", background: ACCENT, border: `3px solid ${FG}` }}>
<span style={{ fontSize: 46 }}>Топ-тег: #{d.topTag}</span>
</div>
) : (
<div style={{ display: "flex" }} />
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end" }}>
<span style={{ fontSize: 34, fontWeight: 700, color: FG }}>second-brain.ru</span>
<span style={{ fontSize: 28, color: MUTED }}>сделай свою на /wrapped</span>
</div>
</div>
);
}