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