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
@@ -0,0 +1,21 @@
-- Obsidian Wrapped — агрегаты прогона (без имён/текстов заметок)
CREATE TABLE "WrappedRun" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"period" TEXT NOT NULL DEFAULT 'year',
"noteCount" INTEGER NOT NULL,
"linkCount" INTEGER NOT NULL,
"tagCount" INTEGER NOT NULL,
"topTag" TEXT,
"archetype" TEXT,
"score" INTEGER NOT NULL DEFAULT 0,
"shareToken" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WrappedRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "WrappedRun_shareToken_key" ON "WrappedRun"("shareToken");
CREATE INDEX "WrappedRun_userId_idx" ON "WrappedRun"("userId");
ALTER TABLE "WrappedRun" ADD CONSTRAINT "WrappedRun_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+21
View File
@@ -45,6 +45,27 @@ model User {
questions StudentQuestion[]
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
questionMessages StudentQuestionMessage[]
wrappedRuns WrappedRun[]
}
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
model WrappedRun {
id String @id @default(cuid())
userId String
period String @default("year") // year | quarter | all
noteCount Int
linkCount Int
tagCount Int
topTag String?
archetype String? // architect | gardener | librarian | pragmatist
score Int @default(0) // 0..100
shareToken String @unique // публичная ссылка /share/wrapped/[token]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model Session {
Binary file not shown.
Binary file not shown.
+258
View File
@@ -0,0 +1,258 @@
"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>
);
}
@@ -0,0 +1,81 @@
// Web Worker: парсит .md-тексты в агрегаты (off-main, без фриза UI).
// Получает RawNote[], возвращает Stats. Тексты заметок дальше воркера не идут.
import type { RawNote, Stats, Period, Archetype } from "./types";
const WIKILINK = /\[\[([^\]|#]+)/g;
const TAG = /(^|\s)#([\p{L}\d_/-]+)/gu;
const DAY = 864e5;
function filterByPeriod(notes: RawNote[], period: Period): RawNote[] {
if (period === "all") return notes;
const cutoff = Date.now() - (period === "year" ? 365 * DAY : 90 * DAY);
return notes.filter((n) => n.mtime >= cutoff);
}
function buildStats(
notes: RawNote[],
degree: Map<string, number>,
tags: Map<string, number>,
linkCount: number,
period: Period,
): Stats {
const noteCount = notes.length;
const topTags = [...tags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, n]) => ({ tag, n }));
const topLinked = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, d]) => ({ name, degree: d }));
// Сирота — заметка, на которую никто не ссылается (грубая оценка по имени).
const linkedTargets = new Set(degree.keys());
const orphans = notes.filter((n) => !linkedTargets.has(n.name.replace(/\.md$/i, ""))).length;
const months = new Map<string, number>();
for (const n of notes) {
const d = new Date(n.mtime);
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
months.set(k, (months.get(k) ?? 0) + 1);
}
const busiestMonth = [...months.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
const linkDensity = noteCount ? linkCount / noteCount : 0;
const tagDensity = noteCount ? tags.size / noteCount : 0;
const orphanRatio = noteCount ? orphans / noteCount : 1;
let archetype: Archetype;
if (linkDensity >= 2) archetype = "architect";
else if (tagDensity >= 0.5) archetype = "librarian";
else if (orphanRatio >= 0.4) archetype = "gardener";
else archetype = "pragmatist";
const score = Math.max(0, Math.min(100, Math.round(
Math.min(linkDensity / 3, 1) * 45 + Math.min(tagDensity, 1) * 25 + Math.min(noteCount / 300, 1) * 30,
)));
const achievements: string[] = [];
if (noteCount >= 100) achievements.push(`${noteCount} заметок`);
if (linkCount >= 200) achievements.push(`${linkCount} связей`);
if (tags.size >= 30) achievements.push(`${tags.size} тегов`);
if (linkDensity >= 3) achievements.push("Плотная сеть знаний");
if (orphanRatio < 0.1 && noteCount >= 50) achievements.push("Почти нет сирот");
return { noteCount, linkCount, tagCount: tags.size, topTags, topLinked, orphans, busiestMonth, achievements, archetype, score, period };
}
self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => {
const { notes, period } = e.data;
const filtered = filterByPeriod(notes, period);
const degree = new Map<string, number>();
const tags = new Map<string, number>();
let linkCount = 0;
for (const n of filtered) {
for (const m of n.text.matchAll(WIKILINK)) {
linkCount++;
const t = m[1].trim();
if (t) degree.set(t, (degree.get(t) ?? 0) + 1);
}
for (const m of n.text.matchAll(TAG)) {
const t = m[2];
tags.set(t, (tags.get(t) ?? 0) + 1);
}
}
self.postMessage(buildStats(filtered, degree, tags, linkCount, period));
};
@@ -0,0 +1,79 @@
// Чтение хранилища в браузере — три пути ввода, все → RawNote[].
// Содержимое заметок остаётся в браузере (приватность-моат).
import type { RawNote } from "./types";
const SKIP = ["/.obsidian/", "/.trash/", "/.git/"];
function isMd(path: string): boolean {
return path.toLowerCase().endsWith(".md") && !SKIP.some((s) => path.includes(s));
}
/* eslint-disable @typescript-eslint/no-explicit-any */
export function supportsFSA(): boolean {
return typeof window !== "undefined" && "showDirectoryPicker" in window;
}
// 1. File System Access API (Chromium: Chrome/Edge/Brave)
export async function readViaFSA(): Promise<RawNote[]> {
const dir: any = await (window as any).showDirectoryPicker();
const notes: RawNote[] = [];
async function walk(handle: any, prefix: string): Promise<void> {
for await (const [name, h] of handle.entries()) {
const path = prefix + "/" + name;
if (h.kind === "directory") {
if (name.startsWith(".")) continue;
await walk(h, path);
} else if (isMd(path)) {
const file: File = await h.getFile();
notes.push({ name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
}
}
}
await walk(dir, "");
return notes;
}
// 2. <input type="file" webkitdirectory> (Safari / Firefox)
export async function readViaInput(files: FileList): Promise<RawNote[]> {
const notes: RawNote[] = [];
for (const file of Array.from(files)) {
const path = (file as any).webkitRelativePath || file.name;
if (!isMd(path)) continue;
notes.push({ name: file.name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
}
return notes;
}
// 3. drag-drop папки (webkitGetAsEntry)
export async function readViaDrop(items: DataTransferItemList): Promise<RawNote[]> {
const notes: RawNote[] = [];
const roots: any[] = [];
for (const item of Array.from(items)) {
const entry = (item as any).webkitGetAsEntry?.();
if (entry) roots.push(entry);
}
async function readEntry(entry: any, prefix: string): Promise<void> {
const path = prefix + "/" + entry.name;
if (entry.isDirectory) {
if (entry.name.startsWith(".")) return;
const reader = entry.createReader();
const children: any[] = await new Promise((resolve) => {
const all: any[] = [];
const readBatch = () =>
reader.readEntries((batch: any[]) => {
if (!batch.length) return resolve(all);
all.push(...batch);
readBatch();
});
readBatch();
});
for (const c of children) await readEntry(c, path);
} else if (isMd(path)) {
const file: File = await new Promise((resolve, reject) => entry.file(resolve, reject));
notes.push({ name: entry.name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
}
}
for (const e of roots) await readEntry(e, "");
return notes;
}
@@ -0,0 +1,39 @@
// Сквозные типы Obsidian Wrapped (общие: reader, worker, рендер, API).
// Приватность: имена/тексты заметок живут ТОЛЬКО в браузере; в БД/сеть уходят
// лишь числовые/строковые агрегаты (RunPayload).
export type Archetype = "architect" | "gardener" | "librarian" | "pragmatist";
export type Period = "year" | "quarter" | "all";
export interface Stats {
noteCount: number;
linkCount: number;
tagCount: number;
topTags: { tag: string; n: number }[]; // top-5
topLinked: { name: string; degree: number }[]; // top-5 самых связанных
orphans: number;
busiestMonth: string; // "2026-03"
achievements: string[];
archetype: Archetype;
score: number; // 0..100
period: Period;
}
// Сырьё — НИКОГДА не покидает браузер.
export interface RawNote {
name: string;
text: string;
ctime: number;
mtime: number;
}
// То, что реально уходит в POST /api/wrapped/run — БЕЗ имён/текстов.
export interface RunPayload {
period: Period;
noteCount: number;
linkCount: number;
tagCount: number;
topTag: string | null;
archetype: Archetype;
score: number;
}
+11
View File
@@ -0,0 +1,11 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { WrappedClient } from "./WrappedClient";
// Авторизованный генератор Obsidian Wrapped. Гость → /register (приток из публичной карточки).
export default async function WrappedPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/register");
return <WrappedClient userName={session.user.name} />;
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { randomBytes } from "crypto";
// Пишет агрегаты прогона Wrapped. userId — строго из сессии, не из тела.
// Имена/тексты заметок сюда не приходят (только числа/строки-агрегаты).
export async function POST(req: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
let b: {
period?: string;
noteCount?: number;
linkCount?: number;
tagCount?: number;
topTag?: string | null;
archetype?: string | null;
score?: number;
};
try {
b = await req.json();
} catch {
return NextResponse.json({ error: "bad json" }, { status: 400 });
}
const period = ["year", "quarter", "all"].includes(String(b.period)) ? String(b.period) : "year";
const VALID = ["architect", "gardener", "librarian", "pragmatist"];
const run = await prisma.wrappedRun.create({
data: {
userId: session.user.id,
period,
noteCount: Number(b.noteCount) || 0,
linkCount: Number(b.linkCount) || 0,
tagCount: Number(b.tagCount) || 0,
topTag: b.topTag ? String(b.topTag).slice(0, 60) : null,
archetype: VALID.includes(String(b.archetype)) ? String(b.archetype) : null,
score: Math.max(0, Math.min(100, Number(b.score) || 0)),
shareToken: randomBytes(9).toString("base64url"),
},
select: { shareToken: true },
});
return NextResponse.json({ shareToken: run.shareToken });
}
@@ -0,0 +1,42 @@
import { ImageResponse } from "next/og";
import { readFile } from "fs/promises";
import path from "path";
import { prisma } from "@/lib/prisma";
import { wrappedCard } from "@/lib/wrapped-card";
import type { Archetype, Period } from "@/app/(student)/wrapped/_client/types";
export const size = { width: 1080, height: 1920 };
export const contentType = "image/png";
export const dynamic = "force-dynamic";
// Серверный PNG 1080×1920 карточки Wrapped по shareToken (для OG-превью в соцсетях
// и для скачивания участником). Рендерится через next/og (Satori + resvg-wasm).
export default async function OG({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const run = await prisma.wrappedRun.findUnique({ where: { shareToken: token } });
const [reg, med] = await Promise.all([
readFile(path.join(process.cwd(), "public/fonts/FiraMono-Regular.ttf")),
readFile(path.join(process.cwd(), "public/fonts/FiraMono-Medium.ttf")),
]);
const data = run
? {
noteCount: run.noteCount,
linkCount: run.linkCount,
tagCount: run.tagCount,
topTag: run.topTag,
archetype: run.archetype as Archetype | null,
score: run.score,
period: run.period as Period,
}
: { noteCount: 0, linkCount: 0, tagCount: 0, topTag: null, archetype: null, score: 0, period: "year" as Period };
return new ImageResponse(wrappedCard(data), {
...size,
fonts: [
{ name: "Fira Mono", data: reg, weight: 400, style: "normal" as const },
{ name: "Fira Mono", data: med, weight: 500, style: "normal" as const },
],
});
}
+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>
);
}
+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>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/maintenance"];
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/maintenance", "/share/wrapped"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;