diff --git a/.env.example b/.env.example index 3a8df4d..968499c 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,9 @@ OPENROUTER_API_KEY=your_openrouter_api_key_here +AI_MODEL=google/gemini-3.1-flash-lite + +# Local development (SQLite file at project root) +DATABASE_URL="file:./dev.db" + +# Production: generate with htpasswd -nB admin | sed 's/\$/\$\$/g' +# Each $ in the bcrypt hash must be written as $$ for docker-compose interpolation +ADMIN_BASIC_AUTH=admin:$$2y$$05$$... diff --git a/.gitignore b/.gitignore index a57fc07..5952cfb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,10 @@ next-env.d.ts .env .env.* -!.env.example \ No newline at end of file +!.env.example +/lib/generated/prisma + +# SQLite database +*.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile index ef232d2..e2e6084 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,38 @@ -FROM node:22 - +# ---- builder ---- +FROM node:22-slim AS builder WORKDIR /app COPY package*.json ./ - -RUN npm install +RUN npm ci COPY . . -RUN npm run build +RUN DATABASE_URL="file:./dev.db" npx prisma generate +RUN DATABASE_URL="file:./dev.db" npm run build + +# ---- runner ---- +FROM node:22-slim AS runner +WORKDIR /app +ENV NODE_ENV=production + +# Next.js standalone output +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +# Prisma client (WASM) + schema + migrations + config +COPY --from=builder /app/lib/generated ./lib/generated +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/prisma.config.ts ./prisma.config.ts + +# Full node_modules for prisma CLI and runtime deps +COPY --from=builder /app/node_modules ./node_modules + +COPY docker-entrypoint.sh ./docker-entrypoint.sh +RUN chmod +x ./docker-entrypoint.sh EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 -CMD ["npm", "start"] \ No newline at end of file +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 8894cd6..a6cb37d 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,91 @@ To regenerate the search index manually after editing page content: node scripts/extract-search-content.mjs ``` -## Docker +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `OPENROUTER_API_KEY` | Yes | API key for the AI chat ([openrouter.ai](https://openrouter.ai)) | +| `AI_MODEL` | No | OpenRouter model ID (default: `google/gemini-3.1-flash-lite`) | +| `DATABASE_URL` | Yes | SQLite connection string — `file:./dev.db` for local dev | + +Copy `.env.example` to `.env` and fill in the values before running the app. + +## Analytics + +Visitor events (file opens, link clicks, sidebar navigation) and chat conversations are stored in a local SQLite database via Prisma. + +Portfolio Overview + +Run the migration once before the first start: + +```bash +npx prisma migrate dev +``` + +The admin dashboard is available at `/admin`. In development it is accessible without authentication. In production it is protected by Traefik BasicAuth (see below). + +## Docker & Traefik deployment + +The `docker-compose.yml` is designed for a server running Traefik as a reverse proxy. + +### 1. Create `.env` on the server + +```bash +cp .env.example .env +``` + +Set at minimum: + +```env +OPENROUTER_API_KEY=sk-or-v1-... + +# Generate with: htpasswd -nB admin | sed 's/\$/\$\$/g' +# Every $ in the bcrypt hash must be written as $$ for docker-compose interpolation +ADMIN_BASIC_AUTH=admin:$$2y$$05$$... +``` + +`DATABASE_URL` is set inside `docker-compose.yml` to `file:/data/portfolio.db` and does **not** need to be in `.env`. + +### 2. Generate the admin password hash + +```bash +# Requires apache2-utils / httpd-tools — or use Docker: +docker run --rm httpd htpasswd -nB admin + +# Escape $ signs for docker-compose (run in bash): +htpasswd -nB admin | sed 's/\$/\$\$/g' +``` + +Paste the result as `ADMIN_BASIC_AUTH` in `.env`. + +### 3. Start the container + +```bash +docker compose pull && docker compose up -d +``` + +The portfolio is then live at `https://merten.tech`. The admin dashboard at `https://merten.tech/admin` is protected by a browser login prompt — Traefik intercepts the request before it reaches the app. + +### How the auth routing works + +Two Traefik routers are configured for the same container: + +| Router | Rule | Middleware | +|---|---|---| +| `merten-portfolio` | `Host(...)` | redirect `mertendieckmann.de → merten.tech` | +| `merten-portfolio-admin` | `Host(...) && PathPrefix(/admin)` | redirect + BasicAuth | + +Traefik automatically assigns higher priority to the more specific `/admin` router (longer rule), so every request to `/admin` is challenged for credentials first. + +### Database persistence + +Analytics data lives in a Docker named volume (`portfolio-data`) mounted at `/data` inside the container. It survives container restarts and image updates. ```bash -docker compose up +# Backup +docker exec merten-portfolio cp /data/portfolio.db /tmp/backup.db +docker cp merten-portfolio:/tmp/backup.db ./portfolio-backup.db ``` ## Adding content diff --git a/actions/muscleGroupApiActions.ts b/actions/muscleGroupApiActions.ts index 5bd66cc..2708728 100644 --- a/actions/muscleGroupApiActions.ts +++ b/actions/muscleGroupApiActions.ts @@ -1,6 +1,8 @@ "use server" export async function fetchAvailableMuscleGroups(): Promise { + return ["biceps", "triceps", "shoulders", "chest", "back", "legs", "core", "glutes", "calves", "forearms"] + return await fetch("https://gym-api.mertendieckmann.de/getMuscleGroups") .then(response => { if (!response.ok) { diff --git a/app/(portfolio)/layout.tsx b/app/(portfolio)/layout.tsx new file mode 100644 index 0000000..a76dcf2 --- /dev/null +++ b/app/(portfolio)/layout.tsx @@ -0,0 +1,37 @@ +"use client" + +import React, { Suspense, useState } from "react" +import { NuqsAdapter } from "nuqs/adapters/next" +import { QueryClientProvider } from "@tanstack/react-query" +import { QueryClient } from "@tanstack/query-core" +import { FileSystemProvider } from "@/context/file-system-context" +import { ChatProvider } from "@/context/chat-context" +import { SideBarProvider } from "@/context/side-bar-context" +import { AppHeader } from "@/components/portfolio/app-header" +import { SideNav } from "@/components/portfolio/side-nav" + +export default function PortfolioLayout({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient()) + + return ( + + + + + + +
+ +
+ + {children} +
+
+
+
+
+
+
+
+ ) +} diff --git a/app/page.tsx b/app/(portfolio)/page.tsx similarity index 97% rename from app/page.tsx rename to app/(portfolio)/page.tsx index 89f5503..5ff51b7 100644 --- a/app/page.tsx +++ b/app/(portfolio)/page.tsx @@ -23,7 +23,7 @@ export default function PortfolioPage() {
(sessions[0] ?? null) + const scrollRef = useRef(null) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [selected]) + + if (sessions.length === 0) { + return ( +
+ +

No chat sessions yet.

+
+ ) + } + + return ( +
+ {/* Session list */} + + + {/* Conversation */} +
+ {selected ? ( + <> +
+
+

{formatDateTimeLong(selected.createdAt)}

+

+ {selected.sessionId} +

+
+ + {selected.messages.length} messages + +
+
+ {selected.messages.map((msg) => + msg.role === "USER" ? ( + + ) : ( + + ) + )} +
+ + ) : ( +
+ Select a session +
+ )} +
+
+ ) +} + +function AssistantBubble({ message }: { message: ChatMessage }) { + return ( +
+
+ +
+
+
+ {message.content} +
+ + {formatTime(message.createdAt)} + +
+
+ ) +} + +function UserBubble({ message }: { message: ChatMessage }) { + return ( +
+
+
+ {message.content} +
+ + {formatTime(message.createdAt)} + +
+
+ +
+
+ ) +} + +function formatDateTime(iso: string) { + const d = new Date(iso) + return d.toLocaleDateString("de", { day: "2-digit", month: "2-digit" }) + + " · " + d.toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit" }) +} + +function formatDateTimeLong(iso: string) { + return new Date(iso).toLocaleString("de", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", + }) +} + +function formatTime(iso: string) { + return new Date(iso).toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit" }) +} diff --git a/app/admin/dashboard.tsx b/app/admin/dashboard.tsx new file mode 100644 index 0000000..348a09e --- /dev/null +++ b/app/admin/dashboard.tsx @@ -0,0 +1,120 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { EventType } from "@/lib/generated/prisma" +import { MetricsTab } from "./metrics-tab" +import { ChatTab } from "./chat-tab" +import { cn } from "@/lib/utils" +import { BarChart2Icon, MessageSquareIcon } from "lucide-react" + +export type SeriesMeta = { name: string; total: number; type: EventType } + +export type ChatMessage = { + id: string + role: string + content: string + createdAt: string +} + +export type ChatSession = { + id: string + sessionId: string + createdAt: string + messages: ChatMessage[] +} + +type Tab = "metrics" | "chat" + +interface Props { + days: number + chartData: Record[] + series: SeriesMeta[] + chatSessions: ChatSession[] +} + +export function AdminDashboard({ days, chartData, series, chatSessions }: Props) { + const [tab, setTab] = useState("metrics") + const router = useRouter() + + function setDays(d: number) { + router.push(`/admin?key=${new URLSearchParams(window.location.search).get("key") ?? ""}&days=${d}`) + } + + return ( +
+ {/* Header */} +
+
+ Portfolio + / +

Analytics

+
+
+ {([7, 30, 90] as const).map((d) => ( + + ))} +
+
+ + {/* Tabs */} +
+ setTab("metrics")} icon={}> + Metrics + + setTab("chat")} icon={}> + Chat Sessions + {chatSessions.length > 0 && ( + + {chatSessions.length} + + )} + +
+ + {/* Content */} +
+ {tab === "metrics" && } + {tab === "chat" && } +
+
+ ) +} + +function TabButton({ + active, + onClick, + icon, + children, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + children: React.ReactNode +}) { + return ( + + ) +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..e8b3735 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next" + +export const metadata: Metadata = { title: "Analytics · Portfolio" } + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/app/admin/metrics-tab.tsx b/app/admin/metrics-tab.tsx new file mode 100644 index 0000000..d2d78f0 --- /dev/null +++ b/app/admin/metrics-tab.tsx @@ -0,0 +1,242 @@ +"use client" + +import { useState, useMemo } from "react" +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts" +import { EventType } from "@/lib/generated/prisma" +import { cn } from "@/lib/utils" +import type { SeriesMeta } from "./dashboard" + +const PALETTE = [ + "#6366f1", "#f59e0b", "#10b981", "#ef4444", + "#3b82f6", "#ec4899", "#14b8a6", "#f97316", + "#8b5cf6", "#84cc16", "#0ea5e9", "#a3e635", +] + +const TYPE_LABEL: Record = { + FILE_OPEN: "File Opens", + EXTERNAL_LINK: "External Links", + SIDEBAR_TAB: "Sidebar", + BUTTON_CLICK: "Buttons", +} + +interface Props { + chartData: Record[] + series: SeriesMeta[] + days: number +} + +export function MetricsTab({ chartData, series, days }: Props) { + const [hidden, setHidden] = useState>(new Set()) + + const colorMap = useMemo(() => { + const m = new Map() + series.forEach((s, i) => m.set(s.name, PALETTE[i % PALETTE.length])) + return m + }, [series]) + + const typeMap = useMemo(() => { + const m = new Map() + series.forEach((s) => m.set(s.name, s.type)) + return m + }, [series]) + + const visibleSeries = series.filter((s) => !hidden.has(s.name)) + + function toggle(name: string) { + setHidden((prev) => { + const next = new Set(prev) + if (next.has(name)) next.delete(name) + else next.add(name) + return next + }) + } + + const totalEvents = series.reduce((s, e) => s + e.total, 0) + + // X-axis tick interval based on range + const tickInterval = days <= 7 ? 0 : days <= 30 ? 3 : 6 + + // Format x-axis dates + const formattedData = chartData.map((row) => ({ + ...row, + dateLabel: formatDateShort(row.date as string), + })) + + if (series.length === 0) { + return ( +
+

No events in the last {days} days.

+
+ ) + } + + // Group series by type + const grouped = Object.entries( + series.reduce>((acc, s) => { + if (!acc[s.type]) acc[s.type] = [] + acc[s.type].push(s) + return acc + }, {}) + ) + + return ( +
+ {/* Summary row */} +
+ + s.type === EventType.FILE_OPEN).reduce((a, s) => a + s.total, 0)} + /> + s.type === EventType.EXTERNAL_LINK).reduce((a, s) => a + s.total, 0)} + /> + s.type === EventType.SIDEBAR_TAB).reduce((a, s) => a + s.total, 0)} + /> +
+ + {/* Chart */} +
+

Events over time

+ + + + + + } /> + {visibleSeries.map((s) => ( + + ))} + + +
+ + {/* Filter chips grouped by type */} +
+ {grouped.map(([type, typeSeries]) => ( +
+

+ {TYPE_LABEL[type as EventType] ?? type} +

+
+ {[...typeSeries].sort((a, b) => a.name.localeCompare(b.name)).map((s) => { + const color = colorMap.get(s.name)! + const isHidden = hidden.has(s.name) + return ( + + ) + })} +
+
+ ))} +
+
+ ) +} + +function ChartTooltip({ + active, + payload, + label, + colorMap, + typeMap, +}: { + active?: boolean + payload?: { dataKey: string; value: number }[] + label?: string + colorMap: Map + typeMap: Map +}) { + if (!active || !payload?.length) return null + const entries = payload.filter((p) => p.value > 0) + if (!entries.length) return null + + return ( +
+

{label}

+
+ {entries.map((entry) => { + const type = typeMap.get(entry.dataKey) + const color = colorMap.get(entry.dataKey) ?? "#888" + return ( +
+ + {entry.dataKey} + {type && ( + + {TYPE_LABEL[type]} + + )} + {entry.value} +
+ ) + })} +
+
+ ) +} + +function StatCard({ label, value }: { label: string; value: number }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function formatDateShort(dateStr: string): string { + const d = new Date(dateStr + "T00:00:00") + return d.toLocaleDateString("en", { month: "short", day: "numeric" }) +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..fb3092a --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,95 @@ +import { prisma } from "@/lib/prisma" +import { EventType } from "@/lib/generated/prisma" +import { AdminDashboard } from "./dashboard" + +export const dynamic = "force-dynamic" + +export default async function AdminPage({ + searchParams, +}: { + searchParams: Promise<{ days?: string }> +}) { + const { days: daysParam } = await searchParams + const days = Number(daysParam ?? 30) + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + + const [rawEvents, chatSessions] = await Promise.all([ + prisma.trackingEvent.findMany({ + where: { createdAt: { gte: since } }, + orderBy: { createdAt: "asc" }, + select: { createdAt: true, type: true, name: true }, + }), + prisma.chatSession.findMany({ + where: { createdAt: { gte: since } }, + include: { messages: { orderBy: { createdAt: "asc" } } }, + orderBy: { createdAt: "desc" }, + }), + ]) + + // Build daily buckets + const buckets = new Map>() + const allNames = new Set() + + for (const e of rawEvents) { + const date = e.createdAt.toISOString().slice(0, 10) + if (!buckets.has(date)) buckets.set(date, new Map()) + const bucket = buckets.get(date)! + bucket.set(e.name, (bucket.get(e.name) ?? 0) + 1) + allNames.add(e.name) + } + + // Fill every date in range + const dateRange: string[] = [] + const cursor = new Date(since) + cursor.setHours(0, 0, 0, 0) + const today = new Date() + today.setHours(23, 59, 59, 999) + while (cursor <= today) { + dateRange.push(cursor.toISOString().slice(0, 10)) + cursor.setDate(cursor.getDate() + 1) + } + + const chartData = dateRange.map((date) => { + const row: Record = { date } + const b = buckets.get(date) + for (const name of allNames) { + row[name] = b?.get(name) ?? 0 + } + return row + }) + + // Series metadata (name → total + type) + const seriesMap = new Map() + for (const e of rawEvents) { + const prev = seriesMap.get(e.name) + seriesMap.set(e.name, { + total: (prev?.total ?? 0) + 1, + type: e.type as EventType, + }) + } + const series = [...seriesMap.entries()] + .map(([name, { total, type }]) => ({ name, total, type })) + .sort((a, b) => b.total - a.total) + + // Serialize dates for client + const serializedSessions = chatSessions.map((s) => ({ + id: s.id, + sessionId: s.sessionId, + createdAt: s.createdAt.toISOString(), + messages: s.messages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + createdAt: m.createdAt.toISOString(), + })), + })) + + return ( + + ) +} diff --git a/app/api/analytics/track/route.ts b/app/api/analytics/track/route.ts new file mode 100644 index 0000000..8ef0492 --- /dev/null +++ b/app/api/analytics/track/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { EventType } from "@/lib/generated/prisma" + +export async function POST(req: Request) { + try { + const { type, name, url, fileId, sessionId } = await req.json() + + if (!type || !name || !sessionId) { + return NextResponse.json({ error: "Missing fields" }, { status: 400 }) + } + + if (!Object.values(EventType).includes(type)) { + return NextResponse.json({ error: "Invalid event type" }, { status: 400 }) + } + + await prisma.trackingEvent.create({ + data: { type, name, url: url ?? null, fileId: fileId ?? null, sessionId }, + }) + } catch { + // silently swallow – analytics must never surface errors to the client + } + + return NextResponse.json({ ok: true }) +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index fa10baf..4dad124 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,13 +4,15 @@ import {readFileSync} from "fs" import {join} from "path" import {getAllFiles} from "@/context/file-system-context-utils"; import {fileSystemContent} from "@/content/file-system-content"; +import {prisma} from "@/lib/prisma"; +import {MessageRole} from "@/lib/generated/prisma"; const knowledgeBase = readFileSync(join(process.cwd(), "content/knowledge-base.md"), "utf-8") const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }) export async function POST(req: Request) { - const { messages, activeFile } = await req.json() + const { messages, activeFile, sessionId } = await req.json() const systemPrompt = [ "You are a helpful assistant on Merten Dieckmann's portfolio website.", @@ -46,16 +48,44 @@ export async function POST(req: Request) { knowledgeBase, ].filter(Boolean).join("\n") - console.log("System Prompt:\n", systemPrompt) - const result = streamText({ // @ts-ignore model: openrouter(process.env.AI_MODEL ?? "google/gemini-3.1-flash-lite"), system: systemPrompt, messages: convertToModelMessages(messages), + onFinish: async ({ text }) => { + await trackChatMessage(sessionId, messages, text) + }, }) return new Response(result.textStream, { headers: { "Content-Type": "text/plain; charset=utf-8" }, }) } + +async function trackChatMessage(sessionId: string, messages: any, text: string) { + + if (!sessionId) return + + const lastUserMessage = messages.findLast((m: { role: string }) => m.role === "user") + + try { + await prisma.chatSession.upsert({ + where: { sessionId }, + create: { sessionId }, + update: {}, + }) + const userText = lastUserMessage?.parts + ?.filter((p: { type: string }) => p.type === "text") + ?.map((p: { text: string }) => p.text) + ?.join("") ?? "" + await prisma.chatMessage.createMany({ + data: [ + ...(userText ? [{ sessionId, role: MessageRole.USER, content: userText }] : []), + { sessionId, role: MessageRole.ASSISTANT, content: text }, + ], + }) + } catch (e) { + console.error("Analytics chat save error:", e) + } +} diff --git a/app/globals.css b/app/globals.css index 6004e08..847d7ed 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,6 +6,7 @@ html, body { height: 100%; + overflow: hidden; } /* Next.js Root-Container soll ebenfalls volle Höhe haben */ diff --git a/app/layout.tsx b/app/layout.tsx index acd251c..eadf62e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,8 @@ import type {Metadata, Viewport} from 'next' import { Space_Grotesk, Inter, JetBrains_Mono } from 'next/font/google' import './globals.css' -import {AppHeader} from "@/components/portfolio/app-header"; import React from "react"; -import Providers from "@/components/providers"; -import {SideNav} from "@/components/portfolio/side-nav"; +import {ThemeProvider} from "next-themes"; const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], @@ -44,15 +42,15 @@ export default function RootLayout({ suppressHydrationWarning className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`} > - -
- -
- - {children} -
-
-
+ + {children} + ) diff --git a/components/pages/cv-page.tsx b/components/pages/cv-page.tsx index dabaaca..59a7d30 100644 --- a/components/pages/cv-page.tsx +++ b/components/pages/cv-page.tsx @@ -1,7 +1,7 @@ "use client" -import Link from "next/link"; import { DownloadIcon } from "lucide-react"; +import { TrackedLink } from "@/components/tracked-link" import { Button } from "@/components/ui/button"; import { FileContentContainer } from "@/components/portfolio/file-content-container"; @@ -13,10 +13,10 @@ export default function CvPage() { subtitle="Merten Dieckmann — Software Engineer" headerRight={ } > diff --git a/components/pages/education/bachelor-page.tsx b/components/pages/education/bachelor-page.tsx index 142bc34..9ba8ed4 100644 --- a/components/pages/education/bachelor-page.tsx +++ b/components/pages/education/bachelor-page.tsx @@ -1,7 +1,7 @@ "use client" -import Link from "next/link" import { GithubIcon, ExternalLinkIcon, DownloadIcon } from "lucide-react" +import { TrackedLink } from "@/components/tracked-link" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -51,28 +51,28 @@ export default function BachelorPage() { headerRight={
} @@ -120,14 +120,14 @@ export default function BachelorPage() {
@@ -146,14 +146,14 @@ export default function BachelorPage() {
diff --git a/components/pages/education/master-page.tsx b/components/pages/education/master-page.tsx index d548ba4..35bf7b4 100644 --- a/components/pages/education/master-page.tsx +++ b/components/pages/education/master-page.tsx @@ -1,7 +1,7 @@ "use client" -import Link from "next/link"; import { GithubIcon, ExternalLinkIcon, DownloadIcon } from "lucide-react"; +import { TrackedLink } from "@/components/tracked-link" import { ZoomableImage } from "@/components/ui/zoomable-image"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -48,22 +48,22 @@ export default function MasterPage() { headerRight={
} diff --git a/components/pages/impressum-page.tsx b/components/pages/impressum-page.tsx index 837e18d..07aa3ac 100644 --- a/components/pages/impressum-page.tsx +++ b/components/pages/impressum-page.tsx @@ -21,7 +21,6 @@ export default function ImpressumPage() {

Merten Dieckmann
Memmingerstrasse 35
- Apt. 2.110
89231 Neu-Ulm

@@ -33,7 +32,6 @@ export default function ImpressumPage() {

- Phone: +49 177 1969628
Email: merten.dieckmann@web.de

@@ -47,7 +45,6 @@ export default function ImpressumPage() {

Merten Dieckmann
Memmingerstrasse 35
- Apt. 2.110
89231 Neu-Ulm

diff --git a/components/pages/projects/bierturnier-page.tsx b/components/pages/projects/bierturnier-page.tsx index fb671e2..d26bc8a 100644 --- a/components/pages/projects/bierturnier-page.tsx +++ b/components/pages/projects/bierturnier-page.tsx @@ -1,7 +1,6 @@ "use client" import Image from "next/image" -import Link from "next/link" import { GithubIcon, ExternalLinkIcon, UsersIcon } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -9,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Separator } from "@/components/ui/separator" import { FileContentContainer } from "@/components/portfolio/file-content-container" import { ZoomableImage } from "@/components/ui/zoomable-image" +import { TrackedLink } from "@/components/tracked-link" const tech = ["Next.js", "TypeScript", "Supabase", "PostgreSQL", "Docker", "PWA", "i18n"] @@ -70,16 +70,16 @@ export default function BierturnierPage() { headerRight={
} diff --git a/components/pages/projects/language-learning-page.tsx b/components/pages/projects/language-learning-page.tsx index 59b7fc6..f1bcf7c 100644 --- a/components/pages/projects/language-learning-page.tsx +++ b/components/pages/projects/language-learning-page.tsx @@ -6,8 +6,8 @@ import { Separator } from "@/components/ui/separator" import { FileContentContainer } from "@/components/portfolio/file-content-container" import { ZoomableImage } from "@/components/ui/zoomable-image" import {Button} from "@/components/ui/button"; -import Link from "next/link"; import {ExternalLinkIcon, GithubIcon} from "lucide-react"; +import { TrackedLink } from "@/components/tracked-link" const tech = ["Next.js", "TypeScript", "tRPC", "Prisma", "PostgreSQL", "Vercel AI SDK", "OpenRouter", "Inngest", "Polar", "PWA"] @@ -33,16 +33,16 @@ export default function LanguageLearningPage() { headerRight={
} diff --git a/components/pages/projects/luzides-traeumen-page.tsx b/components/pages/projects/luzides-traeumen-page.tsx index 0264975..5f15299 100644 --- a/components/pages/projects/luzides-traeumen-page.tsx +++ b/components/pages/projects/luzides-traeumen-page.tsx @@ -3,8 +3,8 @@ import {Badge} from "@/components/ui/badge" import {Button} from "@/components/ui/button" import {ExternalLink, Star, BookOpen, Code2, GithubIcon} from "lucide-react" import {Card, CardContent, CardHeader, CardTitle, CardDescription} from "@/components/ui/card" -import Link from "next/link" import {Fragment} from "react"; +import { TrackedLink } from "@/components/tracked-link" import { FileContentContainer } from "@/components/portfolio/file-content-container"; export default function LuzidesTraeumenPage() { @@ -26,10 +26,10 @@ export default function LuzidesTraeumenPage() { subtitle="A project combining two passions: writing a book about lucid dreaming and building its landing page." headerRight={ } > @@ -60,6 +60,7 @@ export default function LuzidesTraeumenPage() { buttonLink="https://www.amazon.de/Kontrolliere-Deine-Tr%C3%A4ume-praktischer-Leidfaden/dp/B0D8LHZ2X6" buttonText="View on Amazon" buttonVariant="default" + trackingName="LuzidesTraeumen - Amazon" /> {/* The Landing Page Section */} @@ -87,6 +88,7 @@ export default function LuzidesTraeumenPage() { buttonLink="https://luzides-traeumen-buch.de" buttonText="Visit Landing Page" buttonVariant="secondary" + trackingName="LuzidesTraeumen - Landing Page" />
@@ -103,6 +105,7 @@ function ProjectCard({ buttonLink, buttonText, buttonVariant = "default", + trackingName, }: { title: string icon: React.ReactNode @@ -113,6 +116,7 @@ function ProjectCard({ buttonLink: string buttonText: string buttonVariant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" + trackingName: string }) { return ( @@ -135,9 +139,9 @@ function ProjectCard({ ))} diff --git a/components/pages/projects/muscle-group-api-page.tsx b/components/pages/projects/muscle-group-api-page.tsx index 11d3634..6d201df 100644 --- a/components/pages/projects/muscle-group-api-page.tsx +++ b/components/pages/projects/muscle-group-api-page.tsx @@ -5,8 +5,8 @@ import {Button} from "@/components/ui/button"; import {Badge} from "@/components/ui/badge"; import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; import {Separator} from "@/components/ui/separator"; -import Link from "next/link"; import {GithubIcon, ExternalLinkIcon} from "lucide-react"; +import { TrackedLink } from "@/components/tracked-link" const stats = [ { label: "Total requests", value: "500,000+" }, @@ -34,16 +34,16 @@ export default function MuscleGroupAPIPage() { headerRight={
} diff --git a/components/pages/projects/process-flow-page.tsx b/components/pages/projects/process-flow-page.tsx index ed6dc45..9eaa3a2 100644 --- a/components/pages/projects/process-flow-page.tsx +++ b/components/pages/projects/process-flow-page.tsx @@ -1,8 +1,8 @@ "use client" -import Link from "next/link" import Image from "next/image" import { GithubIcon, ExternalLinkIcon } from "lucide-react" +import { TrackedLink } from "@/components/tracked-link" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -77,16 +77,16 @@ export default function ProcessFlowPage() { headerRight={
} diff --git a/components/pages/readme-page.tsx b/components/pages/readme-page.tsx index 2f0a3e1..978ea72 100644 --- a/components/pages/readme-page.tsx +++ b/components/pages/readme-page.tsx @@ -1,10 +1,10 @@ "use client" -import Link from "next/link"; import Image from "next/image"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { TrackedLink } from "@/components/tracked-link" import { Card, CardContent, @@ -76,10 +76,10 @@ export default function ReadmePage() { subtitle="Fullstack Software Engineer @ Mercedes-Benz Tech Innovation" headerRight={ } > diff --git a/components/portfolio/file-content-container.tsx b/components/portfolio/file-content-container.tsx index f635b55..e685f8f 100644 --- a/components/portfolio/file-content-container.tsx +++ b/components/portfolio/file-content-container.tsx @@ -16,7 +16,7 @@ export function FileContentContainer({ headerRight, }: FileContentContainerProps) { return ( -
+
diff --git a/components/portfolio/side-nav.tsx b/components/portfolio/side-nav.tsx index eaa161a..9f6f955 100644 --- a/components/portfolio/side-nav.tsx +++ b/components/portfolio/side-nav.tsx @@ -2,9 +2,9 @@ import {Tab, TabLink, useSideBar} from "@/context/side-bar-context"; import {cn} from "@/lib/utils"; -import Link from "next/link"; import React from "react"; import {navTabLinks, navTabsBottom, navTabsTop} from "@/content/side-nav-content"; +import { trackEvent, EventType } from "@/lib/analytics"; export function SideNav() { @@ -38,7 +38,10 @@ function NavTab({ tab }: { tab: Tab }) { ? "text-primary border-l-2 border-primary bg-ide-hover" : "text-ide-icon-muted opacity-70 hover:text-foreground hover:bg-ide-hover" )} - onClick={() => handleTabClick(tab.id)} + onClick={() => { + trackEvent(EventType.SIDEBAR_TAB, `SideNav - ${tab.label}`) + handleTabClick(tab.id) + }} > @@ -50,19 +53,19 @@ function NavTab({ tab }: { tab: Tab }) { function NavTabLink({ tab }: { tab: TabLink }) { return ( - trackEvent(EventType.EXTERNAL_LINK, `SideNav - ${tab.label}`, { url: tab.href })} > {tab.label} - + ) } diff --git a/components/tracked-link.tsx b/components/tracked-link.tsx new file mode 100644 index 0000000..97cc6ef --- /dev/null +++ b/components/tracked-link.tsx @@ -0,0 +1,33 @@ +"use client" + +import React, { forwardRef } from "react" +import { trackEvent, EventType } from "@/lib/analytics" +import Link from "next/dist/client/link"; + +interface TrackedLinkProps extends React.AnchorHTMLAttributes { + href: string + trackingName: string + trackingType?: EventType + children: React.ReactNode +} + +export const TrackedLink = forwardRef( + function TrackedLink( + { href, trackingName, trackingType = EventType.EXTERNAL_LINK, children, onClick, ...props }, + ref + ) { + return ( + { + trackEvent(trackingType, trackingName, { url: href }) + onClick?.(e) + }} + {...props} + > + {children} + + ) + } +) diff --git a/content/search-index.json b/content/search-index.json index eb8e1d1..138fd4c 100644 --- a/content/search-index.json +++ b/content/search-index.json @@ -3,10 +3,10 @@ "cv": "curriculum vitae merten dieckmann — software engineer cv merten dieckmann", "master": "master's thesis identifying gdpr-critical activities in business processes using large language models gripl — gdpr risk identification in processes using llms backend · frontend · infrastructure key contributions three deliverables developed as part of this thesis classification pipeline bpmn model of the full llm classification pipeline with retry logic gripl analysis tool sandbox view — gdpr-critical activities highlighted in red after llm analysis custom-built web app for creating and annotating gdpr-labeled bpmn test datasets evaluation framework yaml-configured, reproducible llm benchmarking pipeline evaluationcontroller → multievaluationrunner → evaluationrunner → httpevaluator → llm form-based config — define models, pick datasets, set repetitions and concurrency, then launch the run results by model per-model dashboard: radar chart (accuracy / precision / recall / f1), confusion matrix, and run-by-run comparison results by test case bpmn diagram with correctly identified (green) and false-positive (red) activities highlighted, plus model reasoning per element systematic evaluation across 13 llms — recall-oriented metric target f1 ≥ 0.80 llm-based pipeline that classifies bpmn activities as gdpr-critical or non-critical, with structured json output, id validation and automatic retry for format correctness. declarative yaml-configured framework with a standardised http interface for comparing llms and algorithms reproducibly across multiple runs. full-stack web app for creating and maintaining labeled bpmn test datasets across multiple domains and languages, used to build the evaluation dataset. models evaluated models reaching f1 ≥ 0.80 ulm university · software engineering m.sc. · oct 2023 – oct 2025 · grade: 1.0 classification pipeline bpmn diagram gripl sandbox with gdpr-critical bpmn elements highlighted labeling tool editor with bpmn diagram and label panel evaluation framework component diagram evaluation framework configuration ui evaluation results per model with radar and bar charts evaluation results per test case with annotated bpmn diagram f1 scores of all 13 evaluated llms masterarbeit merten dieckmann", "bachelor": "bachelor's thesis design and implementation of a web-based environment for creating and executing gamified bpmn 2.0 models gamified bpmn 2.0 — editor & engine the result is a two-part web platform: a node-based gamification elements five elements integrated into the bpmn runtime to increase engagement and motivation node architecture uml class diagram of the engine's node type system — 7 node types sharing a common interface engine process flow execution loop: import → run node → handle user input → advance to next node → repeat requirements coverage and user testing findings editor requirements met engine requirements met gamification elements user test engagement timed tasks presented as single/multiple-choice questions or free-text inputs. completing them unlocks rewards and advances the process. xp points, coins, and badges are awarded on task completion. badge conditions can be hidden — discovered only through exploration — to incentivise re-runs. a visual overview of the entire process, showing unexplored paths and locked rewards. in user tests, this alone motivated participants to replay the process. ulm university · software engineering b.sc. · oct 2019 – jun 2023 · grade: 1.4 gamificated bpmn editor interface gamificated bpmn engine runtime ui node architecture uml class diagram engine execution process flow diagram bachelorarbeit merten dieckmann", - "biertunrier": "full-stack tournament management for beerpong events collaborative development from idea to production browse all tournaments filtered by state — upcoming, active, or finished. each tournament shows its scheduled date and current participant count. create tournament schedule a new tournament with a name and date. the creator automatically becomes the tournament manager and can control its lifecycle. share a join link or let players scan a qr code to join a tournament directly on their phone — no manual setup required. track all games in a tournament with team matchups, scores, and game states. add new games and record results as the tournament progresses. live standings showing wins, total games played, and win rate per player — updated automatically as game results are recorded. bierturnier icon beerpong tournament manager — built together with Emilija Kastratovic and Markus Thielker.", - "language-learning": "full-stack language learning app with ai chat, spaced repetition, and 11 practice game modes nested word categories with grammatical forms, example sentences, csv import/export, and bulk operations practice session select a vocabulary set and game mode, then work through an adaptive session with a summary at the end practice mini-games eleven interchangeable game modes — same vocabulary, different challenge ai word generation ai-powered language learning — vocabulary, mini-games, and llm conversation practice", + "biertunrier": "full-stack tournament management for beerpong events collaborative development from idea to production browse all tournaments filtered by state — upcoming, active, or finished. each tournament shows its scheduled date and current participant count. create tournament schedule a new tournament with a name and date. the creator automatically becomes the tournament manager and can control its lifecycle. share a join link or let players scan a qr code to join a tournament directly on their phone — no manual setup required. track all games in a tournament with team matchups, scores, and game states. add new games and record results as the tournament progresses. live standings showing wins, total games played, and win rate per player — updated automatically as game results are recorded. bierturnier icon beerpong tournament manager — built together with friends", + "language-learning": "full-stack language learning app with ai chat, spaced repetition, and 11 practice game modes nested word categories with grammatical forms, example sentences, csv import/export, and bulk operations practice session select a vocabulary set and game mode, then work through an adaptive session with a summary at the end practice mini-games eleven interchangeable game modes — same vocabulary, different challenge ai word generation ai-powered language learning — vocabulary, mini-games, and llm conversation practice easylingu vocabulary list easylingu practice session easylingu ai chat tutor easylingu ai word generation easylingu rag scenarios easylingu dashboard", "luzides-traeumen": "lucid dreaming icon a project combining two passions: writing a book about lucid dreaming and building its landing page. a practical guide to lucid dreaming. this book offers a simple and practical entry into the world of lucid dreaming. with step-by-step instructions and many exercises, it the landing page promotional website optimized for conversion. desktop screenshot to market the book, i built a modern, responsive landing page focused on performance and conversion. visit landing page", "muscle-group-api": "musclegroup image generator api self-hosted rest api · deployed on a linux vps · listed on rapidapi monthly requests musclegroup api icon generates an anatomical image where requested muscle groups are dynamically highlighted on the human body in your color of choice.", "process-flow": "gamified business process management process engine architecture drag-and-drop workflow editor design gamified business processes visually. connect activities, gateways, and roles via a node-based editor. each activity node is configurable with gamification rewards directly in the panel. live monitoring dashboard real-time overview of all active process instances — total, completed, in-progress, and blocked. per-process charts show instance trends over time and task progress breakdowns. users see only the tasks assigned to their role. tasks are automatically pushed to the worklist as the process engine advances. each task can be opened, completed, and tracked inline. gamification & statistics every completed task earns xp, coins, and badges based on configurable gamification rules defined in the editor. users track their progress, level, and achievements on their personal stats page. plugin system — activity shop activities are external servers that render custom task uis inside iframes. the shop lists all available activity types — manual or automatic — that can be dropped into any process. teams can publish their own activities. role-based access control every team member gets a role with a configurable color and page permissions. roles control which pages (editor, tasks, monitoring, statistics) a user can access, and which process activities are assigned to them. processflow icon web app for building and executing gamified business processes processflow team dashboard processflow engine architecture diagram", - "imprint": "merten dieckmann memmingerstrasse 35 phone: +49 177 1969628 email: merten.dieckmann@web.de responsible for content eu dispute resolution you can find our email address in the imprint above. consumer dispute resolution / universal arbitration board we are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board. legal information" + "imprint": "merten dieckmann memmingerstrasse 35 email: merten.dieckmann@web.de responsible for content eu dispute resolution you can find our email address in the imprint above. consumer dispute resolution / universal arbitration board we are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board. legal information" } \ No newline at end of file diff --git a/context/chat-context.tsx b/context/chat-context.tsx index 2032ced..94073fe 100644 --- a/context/chat-context.tsx +++ b/context/chat-context.tsx @@ -5,12 +5,16 @@ import { Chat } from "@ai-sdk/react" import { generateId, TextStreamChatTransport } from "ai" function createChatInstance(activeFileRef: React.RefObject) { + const sessionId = crypto.randomUUID() return new Chat({ id: generateId(), // @ts-ignore transport: new TextStreamChatTransport({ api: "/api/chat", - body: () => ({ activeFile: activeFileRef.current }), + body: () => ({ + activeFile: activeFileRef.current, + sessionId, + }), }), }) } diff --git a/context/file-system-context.tsx b/context/file-system-context.tsx index 983c05f..d8e96b4 100644 --- a/context/file-system-context.tsx +++ b/context/file-system-context.tsx @@ -4,6 +4,7 @@ import React, {useContext, useEffect} from "react"; import {useQueryState} from 'nuqs' import {FileId, fileSystemContent} from "@/content/file-system-content"; import {findFileById, getAllFolderIds, getInitiallyClosedFolderIds} from "@/context/file-system-context-utils"; +import {trackEvent, EventType} from "@/lib/analytics"; export type File = { id: string @@ -68,6 +69,7 @@ export function FileSystemProvider({children}: { children: React.ReactNode }) { } const openFile = (file: File) => { + trackEvent(EventType.FILE_OPEN, file.id) setActiveFileId(file.id) setOpenFiles((prev) => { if (!prev.find((f) => f.id === file.id)) { diff --git a/docker-compose.yaml b/docker-compose.yaml index 6a25d6d..a175644 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,22 +5,38 @@ services: networks: - web restart: unless-stopped - env_file: - - .env + volumes: + - portfolio-data:/data + environment: + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - AI_MODEL=${AI_MODEL} + - DATABASE_URL=file:/data/portfolio.db labels: - traefik.enable=true - - traefik.http.routers.merten-portfolio.rule=Host(`mertendieckmann.de`) || Host(`merten.tech`) - - traefik.http.routers.merten-portfolio.entrypoints=websecure - - traefik.http.routers.merten-portfolio.tls=true - - traefik.http.routers.merten-portfolio.tls.certresolver=le-merten - traefik.docker.network=web - com.centurylinklabs.watchtower.enable=true - traefik.http.middlewares.redirect-merten-portfolio.redirectregex.regex=^https?://mertendieckmann.de/(.*) - traefik.http.middlewares.redirect-merten-portfolio.redirectregex.replacement=https://merten.tech/$${1} - traefik.http.middlewares.redirect-merten-portfolio.redirectregex.permanent=true + + - traefik.http.routers.merten-portfolio.rule=Host(`mertendieckmann.de`) || Host(`merten.tech`) + - traefik.http.routers.merten-portfolio.entrypoints=websecure + - traefik.http.routers.merten-portfolio.tls=true + - traefik.http.routers.merten-portfolio.tls.certresolver=le-merten - traefik.http.routers.merten-portfolio.middlewares=redirect-merten-portfolio + - traefik.http.middlewares.admin-auth.basicauth.users=${ADMIN_BASIC_AUTH} + + - traefik.http.routers.merten-portfolio-admin.rule=(Host(`mertendieckmann.de`) || Host(`merten.tech`)) && PathPrefix(`/admin`) + - traefik.http.routers.merten-portfolio-admin.entrypoints=websecure + - traefik.http.routers.merten-portfolio-admin.tls=true + - traefik.http.routers.merten-portfolio-admin.tls.certresolver=le-merten + - traefik.http.routers.merten-portfolio-admin.middlewares=redirect-merten-portfolio,admin-auth + +volumes: + portfolio-data: + networks: web: - external: true \ No newline at end of file + external: true diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..02bb944 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +node_modules/.bin/prisma migrate deploy + +exec node server.js diff --git a/lib/analytics.ts b/lib/analytics.ts new file mode 100644 index 0000000..95180ae --- /dev/null +++ b/lib/analytics.ts @@ -0,0 +1,39 @@ +"use client" + +import { EventType } from "@/lib/generated/prisma" + +const SESSION_KEY = "portfolio-session-id" + +function getSessionId(): string { + if (typeof window === "undefined") return "ssr" + let id = localStorage.getItem(SESSION_KEY) + if (!id) { + id = crypto.randomUUID() + localStorage.setItem(SESSION_KEY, id) + } + return id +} + +export function trackEvent( + type: EventType, + name: string, + opts?: { url?: string; fileId?: string } +) { + try { + fetch("/api/analytics/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type, + name, + url: opts?.url, + fileId: opts?.fileId, + sessionId: getSessionId(), + }), + }).catch(() => {}) + } catch { + // analytics must never break the app + } +} + +export { EventType } diff --git a/lib/prisma.ts b/lib/prisma.ts new file mode 100644 index 0000000..1cff267 --- /dev/null +++ b/lib/prisma.ts @@ -0,0 +1,15 @@ +import { PrismaLibSql } from "@prisma/adapter-libsql" +import { PrismaClient } from "@/lib/generated/prisma" + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } + +function createPrisma() { + const url = process.env.DATABASE_URL + if (!url) throw new Error("DATABASE_URL is not set") + const adapter = new PrismaLibSql({ url }) + return new PrismaClient({ adapter }) +} + +export const prisma = globalForPrisma.prisma ?? createPrisma() + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma diff --git a/next.config.mjs b/next.config.mjs index f04622b..895ae25 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', typescript: { ignoreBuildErrors: true, }, diff --git a/package-lock.json b/package-lock.json index 228486f..9d26c02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "portfolio", - "version": "0.3.0", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "portfolio", - "version": "0.3.0", + "version": "0.3.4", "dependencies": { "@ai-sdk/react": "^3.0.187", "@dnd-kit/core": "^6.3.1", @@ -14,7 +14,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.1", + "@libsql/client": "^0.17.3", "@openrouter/ai-sdk-provider": "^1.4.1", + "@prisma/adapter-libsql": "^7.8.0", + "@prisma/client": "^7.8.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-aspect-ratio": "1.1.8", @@ -55,6 +58,7 @@ "next": "16.2.0", "next-themes": "^0.4.6", "nuqs": "^2.8.9", + "prisma": "^7.8.0", "radix-ui": "^1.4.3", "react": "19.2.4", "react-color": "^2.19.3", @@ -76,6 +80,7 @@ "@types/react": "19.2.14", "@types/react-color": "^3.0.13", "@types/react-dom": "19.2.3", + "dotenv": "^17.4.2", "postcss": "^8.5", "tailwindcss": "^4.2.0", "tw-animate-css": "1.3.3", @@ -321,6 +326,34 @@ "react": ">=16.8.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/runtime": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", @@ -369,6 +402,18 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@hookform/resolvers": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", @@ -903,6 +948,177 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@libsql/client": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.3.tgz", + "integrity": "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.17.3", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.3.tgz", + "integrity": "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, "node_modules/@next/env": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.0.tgz", @@ -1071,6 +1287,203 @@ "node": ">=8.0.0" } }, + "node_modules/@prisma/adapter-libsql": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-libsql/-/adapter-libsql-7.8.0.tgz", + "integrity": "sha512-WnBGVMMlaehdVtWyUydCopL9WjGO77FZaaX0NWbW/Puu7iom3xNM9njpz57QBySbwJSLJMqoIbBPVGcjZ6jk/A==", + "license": "Apache-2.0", + "dependencies": { + "@libsql/client": "^0.17.0", + "@prisma/driver-adapter-utils": "7.8.0", + "async-mutex": "0.5.0" + } + }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.8.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3331,7 +3744,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3387,6 +3799,15 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -3459,6 +3880,22 @@ "node": ">=8.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3471,6 +3908,15 @@ "node": ">=10" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -3507,6 +3953,15 @@ "postcss": "^8.1.0" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -3529,6 +3984,12 @@ "node": ">=6.0.0" } }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -3563,6 +4024,34 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001781", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", @@ -3633,6 +4122,33 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3686,6 +4202,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3865,6 +4401,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3874,6 +4434,12 @@ "node": ">=6" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3913,8 +4479,30 @@ "csstype": "^3.0.2" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.328", + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", "license": "ISC" @@ -3948,6 +4536,15 @@ "embla-carousel": "8.6.0" } }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -3962,6 +4559,18 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3996,12 +4605,46 @@ "node": ">=18.0.0" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -4011,6 +4654,38 @@ "node": ">=6.0.0" } }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4024,6 +4699,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4033,13 +4717,39 @@ "node": ">=6" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "license": "MIT" + }, + "node_modules/giget": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "license": "MIT" + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -4080,6 +4790,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4090,6 +4810,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -4171,16 +4913,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4193,6 +4952,53 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4466,6 +5272,12 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -4488,6 +5300,21 @@ "loose-envify": "cli.js" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/lucide-react": { "version": "0.564.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.564.0.tgz", @@ -5114,6 +5941,38 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5275,6 +6134,12 @@ "node": ">=0.10.0" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -5300,12 +6165,44 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -5341,6 +6238,59 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/prisma": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5358,6 +6308,23 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -5368,6 +6335,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -5583,6 +6566,16 @@ } } }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -5814,6 +6807,19 @@ "lodash": "^4.0.1" } }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/recharts": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", @@ -5879,6 +6885,39 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5898,6 +6937,11 @@ "node": ">=10" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -5943,6 +6987,39 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -5972,6 +7049,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6135,8 +7227,9 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6149,7 +7242,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -6321,6 +7413,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -6384,6 +7490,42 @@ "d3-timer": "^3.0.1" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yet-another-react-lightbox": { "version": "3.32.0", "resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.32.0.tgz", @@ -6410,6 +7552,16 @@ } } }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index e83d759..8ea4b17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portfolio", - "version": "0.3.4", + "version": "0.4.0", "private": true, "scripts": { "dev": "next dev", @@ -16,7 +16,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.1", + "@libsql/client": "^0.17.3", "@openrouter/ai-sdk-provider": "^1.4.1", + "@prisma/adapter-libsql": "^7.8.0", + "@prisma/client": "^7.8.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-aspect-ratio": "1.1.8", @@ -57,6 +60,7 @@ "next": "16.2.0", "next-themes": "^0.4.6", "nuqs": "^2.8.9", + "prisma": "^7.8.0", "radix-ui": "^1.4.3", "react": "19.2.4", "react-color": "^2.19.3", @@ -78,6 +82,7 @@ "@types/react": "19.2.14", "@types/react-color": "^3.0.13", "@types/react-dom": "19.2.3", + "dotenv": "^17.4.2", "postcss": "^8.5", "tailwindcss": "^4.2.0", "tw-animate-css": "1.3.3", diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..69edbe9 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,12 @@ +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/migrations/20260528112512_init/migration.sql b/prisma/migrations/20260528112512_init/migration.sql new file mode 100644 index 0000000..f2a64dd --- /dev/null +++ b/prisma/migrations/20260528112512_init/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "TrackingEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT, + "fileId" TEXT, + "sessionId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "ChatSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "ChatMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ChatMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ChatSession" ("sessionId") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "ChatSession_sessionId_key" ON "ChatSession"("sessionId"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..23a06e9 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,46 @@ +generator client { + provider = "prisma-client-js" + output = "../lib/generated/prisma" +} + +datasource db { + provider = "sqlite" +} + +enum EventType { + BUTTON_CLICK + EXTERNAL_LINK + SIDEBAR_TAB + FILE_OPEN +} + +enum MessageRole { + USER + ASSISTANT +} + +model TrackingEvent { + id String @id @default(cuid()) + type EventType + name String + url String? + fileId String? + sessionId String + createdAt DateTime @default(now()) +} + +model ChatSession { + id String @id @default(cuid()) + sessionId String @unique + messages ChatMessage[] + createdAt DateTime @default(now()) +} + +model ChatMessage { + id String @id @default(cuid()) + chatSession ChatSession @relation(fields: [sessionId], references: [sessionId]) + sessionId String + role MessageRole + content String + createdAt DateTime @default(now()) +} diff --git a/public/images/analytics-overview.png b/public/images/analytics-overview.png new file mode 100644 index 0000000..aab854b Binary files /dev/null and b/public/images/analytics-overview.png differ