diff --git a/prisma/migrations/20260623070000_wrapped_run/migration.sql b/prisma/migrations/20260623070000_wrapped_run/migration.sql new file mode 100644 index 0000000..bfaf562 --- /dev/null +++ b/prisma/migrations/20260623070000_wrapped_run/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1bea6cf..6677b41 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/public/fonts/FiraMono-Medium.ttf b/public/fonts/FiraMono-Medium.ttf new file mode 100644 index 0000000..1e95ced Binary files /dev/null and b/public/fonts/FiraMono-Medium.ttf differ diff --git a/public/fonts/FiraMono-Regular.ttf b/public/fonts/FiraMono-Regular.ttf new file mode 100644 index 0000000..59e1e1a Binary files /dev/null and b/public/fonts/FiraMono-Regular.ttf differ diff --git a/src/app/(student)/wrapped/WrappedClient.tsx b/src/app/(student)/wrapped/WrappedClient.tsx new file mode 100644 index 0000000..43bcd36 --- /dev/null +++ b/src/app/(student)/wrapped/WrappedClient.tsx @@ -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 = { + architect: "🏛 Архитектор", + gardener: "🌱 Садовник", + librarian: "📚 Библиотекарь", + pragmatist: "⚡ Прагматик", +}; +const PERIOD_RU: Record = { year: "год", quarter: "квартал", all: "всё время" }; + +export function WrappedClient({ userName }: { userName: string }) { + const [screen, setScreen] = useState("intro"); + const [stats, setStats] = useState(null); + const [period, setPeriod] = useState("year"); + const [error, setError] = useState(""); + const [shareUrl, setShareUrl] = useState(""); + const [sharing, setSharing] = useState(false); + const inputRef = useRef(null); + const notesRef = useRef([]); + + 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) => { + 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) { + 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 ( +
+

+ Obsidian Wrapped · 2026 +

+ + {screen === "intro" && ( +
+

Твой год в заметках, {userName.split(" ")[0]}

+

+ Выбери папку своего хранилища — всё считается прямо в браузере, заметки никуда не загружаются. + На выходе — карточка «твой год в заметках», которой можно поделиться. +

+
e.preventDefault()} + className="p-8 text-center mb-4" + style={{ border: "2px dashed var(--border)", borderRadius: 2 }} + > +

📂

+ {supportsFSA() ? ( + + ) : ( + + )} +

+ или перетащи папку сюда +

+ +
+

+ 🔒 Приватность: тексты заметок остаются в браузере. На сервер уходят только числа (сколько заметок, связей, тегов). +

+
+ )} + + {screen === "loading" && ( +
+

+

Считаю твой год…

+
+ )} + + {screen === "error" && ( +
+

{error}

+ +
+ )} + + {screen === "result" && stats && ( +
+ {/* переключатель периода */} +
+ {(["year", "quarter", "all"] as Period[]).map((p) => ( + + ))} +
+ + {/* DOM-карточка результата (ДС-2) */} +
+

Ты — {ARCHETYPE_RU[stats.archetype]}

+

+ зрелость {stats.score}/100 · период: {PERIOD_RU[stats.period]} +

+
+ {[ + { n: stats.noteCount, l: "заметок" }, + { n: stats.linkCount, l: "связей" }, + { n: stats.tagCount, l: "тегов" }, + ].map((m) => ( +
+

{m.n}

+

{m.l}

+
+ ))} +
+ {stats.topTags.length > 0 && ( +

+ Топ-теги: + {stats.topTags.map((t) => `#${t.tag}`).join(" ")} +

+ )} + {stats.achievements.length > 0 && ( +
+ {stats.achievements.map((a) => ( + + {a} + + ))} +
+ )} +
+ + {/* действия */} +
+ + +
+ + {shareUrl && ( +
+
+

Публичная ссылка

+ + {shareUrl} + +
+ + Скачать картинку (PNG) + +
+ )} + {error &&

{error}

} +
+ )} +
+ ); +} diff --git a/src/app/(student)/wrapped/_client/parse.worker.ts b/src/app/(student)/wrapped/_client/parse.worker.ts new file mode 100644 index 0000000..b881df3 --- /dev/null +++ b/src/app/(student)/wrapped/_client/parse.worker.ts @@ -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, + tags: Map, + 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(); + 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(); + const tags = new Map(); + 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)); +}; diff --git a/src/app/(student)/wrapped/_client/reader.ts b/src/app/(student)/wrapped/_client/reader.ts new file mode 100644 index 0000000..0e56d01 --- /dev/null +++ b/src/app/(student)/wrapped/_client/reader.ts @@ -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 { + const dir: any = await (window as any).showDirectoryPicker(); + const notes: RawNote[] = []; + async function walk(handle: any, prefix: string): Promise { + 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. (Safari / Firefox) +export async function readViaInput(files: FileList): Promise { + 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 { + 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 { + 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; +} diff --git a/src/app/(student)/wrapped/_client/types.ts b/src/app/(student)/wrapped/_client/types.ts new file mode 100644 index 0000000..c5e18ee --- /dev/null +++ b/src/app/(student)/wrapped/_client/types.ts @@ -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; +} diff --git a/src/app/(student)/wrapped/page.tsx b/src/app/(student)/wrapped/page.tsx new file mode 100644 index 0000000..c402b1d --- /dev/null +++ b/src/app/(student)/wrapped/page.tsx @@ -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 ; +} diff --git a/src/app/api/wrapped/run/route.ts b/src/app/api/wrapped/run/route.ts new file mode 100644 index 0000000..9685a5c --- /dev/null +++ b/src/app/api/wrapped/run/route.ts @@ -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 }); +} diff --git a/src/app/share/wrapped/[token]/opengraph-image.tsx b/src/app/share/wrapped/[token]/opengraph-image.tsx new file mode 100644 index 0000000..a362aaa --- /dev/null +++ b/src/app/share/wrapped/[token]/opengraph-image.tsx @@ -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 }, + ], + }); +} diff --git a/src/app/share/wrapped/[token]/page.tsx b/src/app/share/wrapped/[token]/page.tsx new file mode 100644 index 0000000..053d5a4 --- /dev/null +++ b/src/app/share/wrapped/[token]/page.tsx @@ -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 = { + architect: "Архитектор", + gardener: "Садовник", + librarian: "Библиотекарь", + pragmatist: "Прагматик", +}; + +export async function generateMetadata({ params }: { params: Promise<{ token: string }> }): Promise { + 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) => ( +
+ {n} + {l} +
+ ); + + return ( +
+
+

+ Obsidian Wrapped · 2026 +

+

+ {run.archetype ? ARCHETYPE_RU[run.archetype] : "Карта знаний"} +

+

+ зрелость {run.score}/100 +

+ +
+ {metric(run.noteCount, "заметок")} + {metric(run.linkCount, "связей")} + {metric(run.tagCount, "тегов")} +
+ + {run.topTag && ( +
+ Топ-тег: #{run.topTag} +
+ )} + +
+ + Сделать свою карточку → + + + Скачать картинку (1080×1920) + +
+ +

+ 🔒 Здесь только числа. Содержимое заметок остаётся на устройстве автора. +

+
+
+ ); +} diff --git a/src/lib/wrapped-card.tsx b/src/lib/wrapped-card.tsx new file mode 100644 index 0000000..74e9b93 --- /dev/null +++ b/src/lib/wrapped-card.tsx @@ -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 = { + architect: "Архитектор", + gardener: "Садовник", + librarian: "Библиотекарь", + pragmatist: "Прагматик", +}; +const PERIOD_RU: Record = { 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 ( +
+ {n} + {l} +
+ ); +} + +export function wrappedCard(d: CardData) { + return ( +
+
+ + Obsidian Wrapped · 2026 + + + {d.archetype ? ARCHETYPE_RU[d.archetype] : "Твой год"} + + + зрелость {d.score}/100 · {PERIOD_RU[d.period] ?? "год"} + +
+ +
+ + + +
+ + {d.topTag ? ( +
+ Топ-тег: #{d.topTag} +
+ ) : ( +
+ )} + +
+ second-brain.ru + сделай свою на /wrapped +
+
+ ); +} diff --git a/src/middleware.ts b/src/middleware.ts index 174621c..9d16a6d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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;