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,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;
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user