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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user