@@ -19,15 +51,15 @@ export default function PrivacyPolicy() {
className="mb-8"
>
- Back to Home
+ {t("backToHome")}
- Datenschutzerklärung
+ {cmsTitle || "Datenschutzerklärung"}
@@ -37,59 +69,77 @@ export default function PrivacyPolicy() {
transition={{ duration: 0.8, delay: 0.2 }}
className="glass-card p-8 rounded-2xl space-y-6 text-white"
>
-
-
- Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
- über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
-
-
+ {cmsDoc ? (
+
+ ) : (
+ <>
+
+
+ Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
+ über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
+
+
-
-
- Verantwortlicher für die Datenverarbeitung
-
-
-
Name: Dennis Konkol
-
Adresse: Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
-
E-Mail: info@dk0.dev
-
Website: dk0.dev
-
-
- Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten Verantwortlichen.
-
-
-
- Erfassung allgemeiner Informationen beim Besuch meiner Website
-
-
- Beim Zugriff auf meiner Website werden automatisch Informationen
- allgemeiner Natur erfasst. Diese beinhalten unter anderem:
-
- IP-Adresse (in anonymisierter Form)
- Uhrzeit
- Browsertyp
- Verwendetes Betriebssystem
- Referrer-URL (die zuvor besuchte Seite)
-
-
- Diese Informationen werden anonymisiert erfasst und dienen
- ausschließlich statistischen Auswertungen. Rückschlüsse auf Ihre
- Person sind nicht möglich. Diese Daten werden verarbeitet, um:
-
- die Inhalte meiner Website korrekt auszuliefern,
- die Inhalte meiner Website zu optimieren,
- die Systemsicherheit und -stabilität zu analysiern.
-
-
-
Cookies
-
- Meine Website verwendet keine Cookies. Daher ist kein
- Cookie-Consent-Banner erforderlich.
-
-
- Analyse- und Tracking-Tools
-
-
+
+
Verantwortlicher für die Datenverarbeitung
+
+
+ Name: Dennis Konkol
+
+
+ Adresse: Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
+
+
+ E-Mail: {" "}
+
+ info@dk0.dev
+
+
+
+ Website: {" "}
+
+ dk0.dev
+
+
+
+
+ Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten
+ Verantwortlichen.
+
+
+
+
+ Erfassung allgemeiner Informationen beim Besuch meiner Website
+
+
+ Beim Zugriff auf meiner Website werden automatisch Informationen allgemeiner Natur erfasst. Diese
+ beinhalten unter anderem:
+
+ IP-Adresse (in anonymisierter Form)
+ Uhrzeit
+ Browsertyp
+ Verwendetes Betriebssystem
+ Referrer-URL (die zuvor besuchte Seite)
+
+
+ Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen.
+ Rückschlüsse auf Ihre Person sind nicht möglich. Diese Daten werden verarbeitet, um:
+
+ die Inhalte meiner Website korrekt auszuliefern,
+ die Inhalte meiner Website zu optimieren,
+ die Systemsicherheit und -stabilität zu analysiern.
+
+
+
+
Cookies
+
+ Diese Website verwendet ein technisch notwendiges Cookie, um deine Datenschutz-Einstellungen (z.B.
+ Analytics/Chatbot) zu speichern. Ohne dieses Cookie wäre ein Consent-Banner bei jedem Besuch erneut
+ nötig.
+
+
+
Analyse- und Tracking-Tools
+
Die nachfolgend beschriebene Analyse- und Tracking-Methode (im
Folgenden „Maßnahme“ genannt) basiert auf Art. 6 Abs. 1 S. 1 lit. f
DSGVO. Durch diese Maßnahme möchten ich eine benutzerfreundliche
@@ -118,6 +168,11 @@ export default function PrivacyPolicy() {
.
+
+ Zusätzlich kann diese Website optionale, selbst gehostete
+ Nutzungsstatistiken erfassen (z.B. Seitenaufrufe, Performance-Metriken),
+ die erst nach deiner Einwilligung im Consent-Banner aktiviert werden.
+
Kontaktformular
Wenn Sie das Kontaktformular nutzen, werden Ihre Angaben zur
@@ -126,6 +181,17 @@ export default function PrivacyPolicy() {
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung).
+
Chatbot
+
+ Wenn du den optionalen Chatbot nutzt, werden die von dir eingegebenen
+ Nachrichten verarbeitet, um eine Antwort zu generieren. Die Verarbeitung
+ kann dabei über eine selbst gehostete Automations-/Chat-Infrastruktur
+ (z.B. n8n) erfolgen. Bitte gib im Chat keine sensiblen Daten ein.
+
+
+ Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung) – der
+ Chatbot wird erst nach Aktivierung im Consent-Banner geladen.
+
Social Media Links
Unsere Website enthält Links zu GitHub und LinkedIn. Durch das
@@ -233,6 +299,8 @@ export default function PrivacyPolicy() {
Letzte Aktualisierung: 12.02.2025
+ >
+ )}
diff --git a/app/projects/[slug]/page.tsx b/app/projects/[slug]/page.tsx
index 7a6b4a7..0db386d 100644
--- a/app/projects/[slug]/page.tsx
+++ b/app/projects/[slug]/page.tsx
@@ -6,9 +6,11 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
+import { useLocale, useTranslations } from "next-intl";
interface Project {
id: number;
+ slug: string;
title: string;
description: string;
content: string;
@@ -24,6 +26,8 @@ interface Project {
const ProjectDetail = () => {
const params = useParams();
const slug = params.slug as string;
+ const locale = useLocale();
+ const t = useTranslations("common");
const [project, setProject] = useState
(null);
// Load project from API by slug
@@ -90,11 +94,11 @@ const ProjectDetail = () => {
className="mb-8"
>
- Back to Projects
+ {t("backToProjects")}
diff --git a/app/projects/page.tsx b/app/projects/page.tsx
index 00d8793..395f176 100644
--- a/app/projects/page.tsx
+++ b/app/projects/page.tsx
@@ -4,9 +4,11 @@ import { useState, useEffect } from "react";
import { motion } from 'framer-motion';
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from 'lucide-react';
import Link from 'next/link';
+import { useLocale, useTranslations } from "next-intl";
interface Project {
id: number;
+ slug: string;
title: string;
description: string;
content: string;
@@ -26,6 +28,8 @@ const ProjectsPage = () => {
const [selectedCategory, setSelectedCategory] = useState("All");
const [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false);
+ const locale = useLocale();
+ const t = useTranslations("common");
// Load projects from API
useEffect(() => {
@@ -87,11 +91,11 @@ const ProjectsPage = () => {
className="mb-12"
>
- Back to Home
+ {t("backToHome")}
@@ -222,7 +226,7 @@ const ProjectsPage = () => {
{/* Stretched Link covering the whole card (including image area) */}
diff --git a/app/robots.txt/route.ts b/app/robots.txt/route.ts
new file mode 100644
index 0000000..7c25572
--- /dev/null
+++ b/app/robots.txt/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import { getBaseUrl } from "@/lib/seo";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const base = getBaseUrl();
+ const body = [
+ "User-agent: *",
+ "Allow: /",
+ "Disallow: /api/",
+ "Disallow: /manage",
+ "Disallow: /editor",
+ `Sitemap: ${base}/sitemap.xml`,
+ "",
+ ].join("\n");
+
+ return new NextResponse(body, {
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+}
+
diff --git a/app/sitemap.xml/route.tsx b/app/sitemap.xml/route.tsx
index 7a1abd4..2294402 100644
--- a/app/sitemap.xml/route.tsx
+++ b/app/sitemap.xml/route.tsx
@@ -1,67 +1,20 @@
import { NextResponse } from "next/server";
+import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
export const dynamic = "force-dynamic";
export async function GET() {
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
- const apiUrl = `${baseUrl}/api/sitemap`; // Verwende die vollständige URL zur API
-
- // In test runs, allow returning a mocked sitemap explicitly
- if (process.env.NODE_ENV === "test" && process.env.GHOST_MOCK_SITEMAP) {
- // For tests return a simple object so tests can inspect `.body`
- if (process.env.NODE_ENV === "test") {
- /* eslint-disable @typescript-eslint/no-explicit-any */
- return {
- body: process.env.GHOST_MOCK_SITEMAP,
- headers: { "Content-Type": "application/xml" },
- } as any;
- /* eslint-enable @typescript-eslint/no-explicit-any */
- }
- return new NextResponse(process.env.GHOST_MOCK_SITEMAP, {
- headers: { "Content-Type": "application/xml" },
- });
- }
-
try {
- // Holt die Sitemap-Daten von der API
- // Try global fetch first, then fall back to node-fetch
- /* eslint-disable @typescript-eslint/no-explicit-any */
- let res: any;
- try {
- if (typeof (globalThis as any).fetch === "function") {
- res = await (globalThis as any).fetch(apiUrl);
- }
- } catch (_e) {
- res = undefined;
- }
-
- if (!res || typeof res.ok === "undefined" || !res.ok) {
- try {
- const mod = await import("node-fetch");
- const nodeFetch = (mod as any).default ?? mod;
- res = await (nodeFetch as any)(apiUrl);
- } catch (err) {
- console.error("Error fetching sitemap:", err);
- return new NextResponse("Error fetching sitemap", { status: 500 });
- }
- }
- /* eslint-enable @typescript-eslint/no-explicit-any */
-
- if (!res || !res.ok) {
- console.error(
- `Failed to fetch sitemap: ${res?.statusText ?? "no response"}`,
- );
- return new NextResponse("Failed to fetch sitemap", { status: 500 });
- }
-
- const xml = await res.text();
-
- // Gibt die XML mit dem richtigen Content-Type zurück
+ const entries = await getSitemapEntries();
+ const xml = generateSitemapXml(entries);
return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" },
});
} catch (error) {
- console.error("Error fetching sitemap:", error);
- return new NextResponse("Error fetching sitemap", { status: 500 });
+ console.error("Error generating sitemap.xml:", error);
+ return new NextResponse(generateSitemapXml([]), {
+ status: 500,
+ headers: { "Content-Type": "application/xml" },
+ });
}
}
diff --git a/components/AnalyticsDashboard.tsx b/components/AnalyticsDashboard.tsx
index 727f2a1..fe0bf1a 100644
--- a/components/AnalyticsDashboard.tsx
+++ b/components/AnalyticsDashboard.tsx
@@ -72,15 +72,16 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
try {
setLoading(true);
setError(null);
+ const sessionToken = sessionStorage.getItem('admin_session_token') || '';
// Add cache-busting parameter to ensure fresh data after reset
const cacheBust = `?nocache=true&t=${Date.now()}`;
const [analyticsRes, performanceRes] = await Promise.all([
fetch(`/api/analytics/dashboard${cacheBust}`, {
- headers: { 'x-admin-request': 'true' }
+ headers: { 'x-admin-request': 'true', 'x-session-token': sessionToken }
}),
fetch(`/api/analytics/performance${cacheBust}`, {
- headers: { 'x-admin-request': 'true' }
+ headers: { 'x-admin-request': 'true', 'x-session-token': sessionToken }
})
]);
@@ -128,11 +129,13 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
setResetting(true);
setError(null);
try {
+ const sessionToken = sessionStorage.getItem('admin_session_token') || '';
const response = await fetch('/api/analytics/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'x-admin-request': 'true'
+ 'x-admin-request': 'true',
+ 'x-session-token': sessionToken
},
body: JSON.stringify({ type: resetType })
});
diff --git a/components/AnalyticsProvider.tsx b/components/AnalyticsProvider.tsx
index 5a547a7..52e5bd6 100644
--- a/components/AnalyticsProvider.tsx
+++ b/components/AnalyticsProvider.tsx
@@ -69,6 +69,10 @@ export const AnalyticsProvider: React.FC
= ({ children }
// Track performance metrics to our API
const trackPerformanceToAPI = async () => {
try {
+ if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
+ return;
+ }
+
// Get current page path to extract project ID if on project page
const path = window.location.pathname;
const projectMatch = path.match(/\/projects\/([^\/]+)/);
@@ -189,6 +193,7 @@ export const AnalyticsProvider: React.FC = ({ children }
// Track scroll depth
let maxScrollDepth = 0;
+ const firedScrollMilestones = new Set();
const handleScroll = () => {
try {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
@@ -202,18 +207,14 @@ export const AnalyticsProvider: React.FC = ({ children }
(window.scrollY / (scrollHeight - innerHeight)) * 100
);
- if (scrollDepth > maxScrollDepth) {
- maxScrollDepth = scrollDepth;
-
- // Track scroll milestones
- if (scrollDepth >= 25 && scrollDepth < 50 && maxScrollDepth >= 25) {
- trackEvent('scroll-depth', { depth: 25, url: window.location.pathname });
- } else if (scrollDepth >= 50 && scrollDepth < 75 && maxScrollDepth >= 50) {
- trackEvent('scroll-depth', { depth: 50, url: window.location.pathname });
- } else if (scrollDepth >= 75 && scrollDepth < 90 && maxScrollDepth >= 75) {
- trackEvent('scroll-depth', { depth: 75, url: window.location.pathname });
- } else if (scrollDepth >= 90 && maxScrollDepth >= 90) {
- trackEvent('scroll-depth', { depth: 90, url: window.location.pathname });
+ if (scrollDepth > maxScrollDepth) maxScrollDepth = scrollDepth;
+
+ // Track each milestone once (avoid spamming events on every scroll tick)
+ const milestones = [25, 50, 75, 90];
+ for (const milestone of milestones) {
+ if (maxScrollDepth >= milestone && !firedScrollMilestones.has(milestone)) {
+ firedScrollMilestones.add(milestone);
+ trackEvent('scroll-depth', { depth: milestone, url: window.location.pathname });
}
}
} catch (error) {
@@ -269,6 +270,8 @@ export const AnalyticsProvider: React.FC = ({ children }
// Cleanup
return () => {
try {
+ // Remove load handler if we added it
+ window.removeEventListener('load', trackPerformanceToAPI);
window.removeEventListener('popstate', handleRouteChange);
document.removeEventListener('click', handleClick);
document.removeEventListener('submit', handleSubmit);
diff --git a/components/ContentManager.tsx b/components/ContentManager.tsx
new file mode 100644
index 0000000..e9249a3
--- /dev/null
+++ b/components/ContentManager.tsx
@@ -0,0 +1,414 @@
+'use client';
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { EditorContent, useEditor, type JSONContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Underline from '@tiptap/extension-underline';
+import Link from '@tiptap/extension-link';
+import { TextStyle } from '@tiptap/extension-text-style';
+import Color from '@tiptap/extension-color';
+import Highlight from '@tiptap/extension-highlight';
+import { Bold, Italic, Underline as UnderlineIcon, List, ListOrdered, Link as LinkIcon, Highlighter, Type, Save, RefreshCw } from 'lucide-react';
+import { FontFamily, type AllowedFontFamily } from '@/lib/tiptap/fontFamily';
+
+const EMPTY_DOC: JSONContent = {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }],
+};
+
+type PageListItem = {
+ id: number;
+ key: string;
+ translations: Array<{ locale: string; updatedAt: string; title: string | null; slug: string | null }>;
+};
+
+export default function ContentManager() {
+ const [pages, setPages] = useState([]);
+ const [selectedKey, setSelectedKey] = useState('privacy-policy');
+ const [selectedLocale, setSelectedLocale] = useState('de');
+ const [title, setTitle] = useState('');
+ const [slug, setSlug] = useState('');
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [fontFamily, setFontFamily] = useState('');
+ const [color, setColor] = useState('#111827');
+
+ const extensions = useMemo(
+ () => [
+ StarterKit,
+ Underline,
+ Link.configure({
+ openOnClick: false,
+ HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
+ }),
+ TextStyle,
+ FontFamily,
+ Color,
+ Highlight,
+ ],
+ [],
+ );
+
+ const editor = useEditor({
+ extensions,
+ content: EMPTY_DOC,
+ editorProps: {
+ attributes: {
+ class:
+ 'prose prose-stone max-w-none focus:outline-none min-h-[320px] p-4 bg-white rounded-xl border border-stone-200',
+ },
+ },
+ });
+
+ const sessionHeaders = () => {
+ const sessionToken = sessionStorage.getItem('admin_session_token') || '';
+ return {
+ 'x-admin-request': 'true',
+ 'x-session-token': sessionToken,
+ 'Content-Type': 'application/json',
+ };
+ };
+
+ const loadPages = useCallback(async () => {
+ setError('');
+ try {
+ setIsLoading(true);
+ const res = await fetch('/api/content/pages', { headers: sessionHeaders() });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data?.error || 'Failed to load content pages');
+ setPages(data.pages || []);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to load content pages');
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ const loadSelected = useCallback(async () => {
+ if (!editor) return;
+ setError('');
+ try {
+ setIsLoading(true);
+ const res = await fetch(`/api/content/page?key=${encodeURIComponent(selectedKey)}&locale=${encodeURIComponent(selectedLocale)}`);
+ const data = await res.json();
+ const translation = data?.content;
+
+ const nextTitle = (translation?.title as string | undefined) || '';
+ const nextSlug = (translation?.slug as string | undefined) || '';
+ const nextDoc = (translation?.content as JSONContent | undefined) || EMPTY_DOC;
+
+ setTitle(nextTitle);
+ setSlug(nextSlug);
+ editor.commands.setContent(nextDoc);
+ setFontFamily('');
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to load content');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [editor, selectedKey, selectedLocale]);
+
+ useEffect(() => {
+ loadPages();
+ }, [loadPages]);
+
+ useEffect(() => {
+ loadSelected();
+ }, [loadSelected]);
+
+ const handleSave = async () => {
+ if (!editor) return;
+ setError('');
+ try {
+ setIsSaving(true);
+ const content = editor.getJSON();
+ const res = await fetch('/api/content/pages', {
+ method: 'POST',
+ headers: sessionHeaders(),
+ body: JSON.stringify({
+ key: selectedKey,
+ locale: selectedLocale,
+ title: title || null,
+ slug: slug || null,
+ content,
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data?.error || 'Failed to save content');
+ await loadPages();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to save content');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const localeOptions = ['en', 'de'];
+ const fontOptions: Array<{ label: string; value: AllowedFontFamily | '' }> = [
+ { label: 'Default', value: '' },
+ { label: 'Inter', value: 'Inter' },
+ { label: 'Sans', value: 'ui-sans-serif' },
+ { label: 'Serif', value: 'ui-serif' },
+ { label: 'Mono', value: 'ui-monospace' },
+ ];
+
+ const selectedInfo = useMemo(() => {
+ const page = pages.find((p) => p.key === selectedKey);
+ const tr = page?.translations?.find((t) => t.locale === selectedLocale);
+ return tr;
+ }, [pages, selectedKey, selectedLocale]);
+
+ return (
+
+
+
+
Content Manager
+
+ Edit texts/pages with rich formatting (bold, underline, links, highlights).
+
+
+
+
+ Refresh
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ Page key
+ setSelectedKey(e.target.value)}
+ className="w-full px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300"
+ >
+ {pages.map((p) => (
+
+ {p.key}
+
+ ))}
+ {pages.length === 0 && (
+ <>
+ privacy-policy
+ legal-notice
+ home-hero
+ >
+ )}
+
+
+
+
+ Locale
+ setSelectedLocale(e.target.value)}
+ className="w-full px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300"
+ >
+ {localeOptions.map((l) => (
+
+ {l}
+
+ ))}
+
+
+
+
+ Last updated:{' '}
+
+ {selectedInfo?.updatedAt ? new Date(selectedInfo.updatedAt).toLocaleString() : '—'}
+
+
+
+
+
+
+ Title (optional)
+ setTitle(e.target.value)}
+ className="w-full px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300"
+ placeholder="Page title"
+ />
+
+
+ Slug (optional)
+ setSlug(e.target.value)}
+ className="w-full px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300"
+ placeholder="privacy-policy"
+ />
+
+
+
+
+ {isSaving ? 'Saving…' : 'Save'}
+
+
+
+
+
+
+
Content
+ {isLoading ? (
+
Loading…
+ ) : (
+ <>
+ {editor && (
+
+
editor.chain().focus().toggleBold().run()}
+ className={`p-2 rounded-lg border transition-colors ${
+ editor.isActive('bold')
+ ? 'bg-stone-900 text-stone-50 border-stone-900'
+ : 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
+ }`}
+ title="Bold"
+ >
+
+
+
editor.chain().focus().toggleItalic().run()}
+ className={`p-2 rounded-lg border transition-colors ${
+ editor.isActive('italic')
+ ? 'bg-stone-900 text-stone-50 border-stone-900'
+ : 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
+ }`}
+ title="Italic"
+ >
+
+
+
editor.chain().focus().toggleUnderline().run()}
+ className={`p-2 rounded-lg border transition-colors ${
+ editor.isActive('underline')
+ ? 'bg-stone-900 text-stone-50 border-stone-900'
+ : 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
+ }`}
+ title="Underline"
+ >
+
+
+
editor.chain().focus().toggleHighlight().run()}
+ className={`p-2 rounded-lg border transition-colors ${
+ editor.isActive('highlight')
+ ? 'bg-stone-900 text-stone-50 border-stone-900'
+ : 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
+ }`}
+ title="Highlight"
+ >
+
+
+
editor.chain().focus().toggleBulletList().run()}
+ className={`p-2 rounded-lg border transition-colors ${
+ editor.isActive('bulletList')
+ ? 'bg-stone-900 text-stone-50 border-stone-900'
+ : 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
+ }`}
+ title="Bullet list"
+ >
+
+
+
editor.chain().focus().toggleOrderedList().run()}
+ className={`p-2 rounded-lg border transition-colors ${
+ editor.isActive('orderedList')
+ ? 'bg-stone-900 text-stone-50 border-stone-900'
+ : 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
+ }`}
+ title="Ordered list"
+ >
+
+
+
+
{
+ const prev = editor.getAttributes('link')?.href as string | undefined;
+ const href = prompt('Enter URL', prev || 'https://');
+ if (!href) return;
+ editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
+ }}
+ className={`p-2 rounded-lg border transition-colors ${
+ editor.isActive('link')
+ ? 'bg-stone-900 text-stone-50 border-stone-900'
+ : 'bg-white text-stone-700 border-stone-200 hover:bg-stone-50'
+ }`}
+ title="Link"
+ >
+
+
+
+
+
+ {
+ const next = e.target.value as AllowedFontFamily | '';
+ setFontFamily(next);
+ if (!next) {
+ editor.chain().focus().unsetFontFamily().run();
+ } else {
+ editor.chain().focus().setFontFamily(next).run();
+ }
+ }}
+ className="px-3 py-2 bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-stone-300 text-sm"
+ title="Font family"
+ >
+ {fontOptions.map((f) => (
+
+ {f.label}
+
+ ))}
+
+
+ {
+ const next = e.target.value;
+ setColor(next);
+ editor.chain().focus().setColor(next).run();
+ }}
+ className="w-10 h-10 p-1 bg-white border border-stone-200 rounded-lg"
+ title="Text color"
+ />
+
+
+ )}
+
+ >
+ )}
+
+ Tip: Use bold/underline, links, lists, headings. (Email-safe rendering is handled separately.)
+
+
+
+
+
+ );
+}
+
diff --git a/components/EmailManager.tsx b/components/EmailManager.tsx
index 60f5923..e5c49e8 100644
--- a/components/EmailManager.tsx
+++ b/components/EmailManager.tsx
@@ -42,9 +42,11 @@ export const EmailManager: React.FC = () => {
const loadMessages = async () => {
try {
setIsLoading(true);
+ const sessionToken = sessionStorage.getItem('admin_session_token') || '';
const response = await fetch('/api/contacts', {
headers: {
- 'x-admin-request': 'true'
+ 'x-admin-request': 'true',
+ 'x-session-token': sessionToken
}
});
@@ -100,10 +102,13 @@ export const EmailManager: React.FC = () => {
if (!selectedMessage || !replyContent.trim()) return;
try {
+ const sessionToken = sessionStorage.getItem('admin_session_token') || '';
const response = await fetch('/api/email/respond', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+ 'x-admin-request': 'true',
+ 'x-session-token': sessionToken,
},
body: JSON.stringify({
to: selectedMessage.email,
@@ -115,6 +120,24 @@ export const EmailManager: React.FC = () => {
});
if (response.ok) {
+ // Persist responded status in DB
+ try {
+ await fetch(`/api/contacts/${selectedMessage.id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-admin-request': 'true',
+ 'x-session-token': sessionToken,
+ },
+ body: JSON.stringify({
+ responded: true,
+ responseTemplate: 'reply',
+ }),
+ });
+ } catch {
+ // ignore persistence failures
+ }
+
setMessages(prev => prev.map(msg =>
msg.id === selectedMessage.id ? { ...msg, responded: true } : msg
));
diff --git a/components/ImportExport.tsx b/components/ImportExport.tsx
index 2280cf4..f354a4f 100644
--- a/components/ImportExport.tsx
+++ b/components/ImportExport.tsx
@@ -23,14 +23,20 @@ export default function ImportExport() {
const handleExport = async () => {
setIsExporting(true);
try {
- const response = await fetch('/api/projects/export');
+ const sessionToken = sessionStorage.getItem('admin_session_token') || '';
+ const response = await fetch('/api/projects/export', {
+ headers: {
+ 'x-admin-request': 'true',
+ 'x-session-token': sessionToken,
+ }
+ });
if (!response.ok) throw new Error('Export failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
- a.download = `portfolio-projects-${new Date().toISOString().split('T')[0]}.json`;
+ a.download = `portfolio-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
@@ -63,9 +69,14 @@ export default function ImportExport() {
const text = await file.text();
const data = JSON.parse(text);
+ const sessionToken = sessionStorage.getItem('admin_session_token') || '';
const response = await fetch('/api/projects/import', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-admin-request': 'true',
+ 'x-session-token': sessionToken,
+ },
body: JSON.stringify(data)
});
@@ -108,9 +119,9 @@ export default function ImportExport() {
{/* Export Section */}
-
Export Projekte
+
Backup Export (Projekte + CMS)
- Alle Projekte als JSON-Datei herunterladen
+ Vollständiges Backup als JSON herunterladen (inkl. CMS Inhalte und Übersetzungen)
- Import Projekte
+ Backup Import
- JSON-Datei mit Projekten hochladen
+ JSON-Datei mit Backup hochladen (Projekte + CMS + Übersetzungen)
diff --git a/components/ModernAdminDashboard.tsx b/components/ModernAdminDashboard.tsx
index 1f9e10e..b3214da 100644
--- a/components/ModernAdminDashboard.tsx
+++ b/components/ModernAdminDashboard.tsx
@@ -17,10 +17,28 @@ import {
X
} from 'lucide-react';
import Link from 'next/link';
-import { EmailManager } from './EmailManager';
-import { AnalyticsDashboard } from './AnalyticsDashboard';
-import ImportExport from './ImportExport';
-import { ProjectManager } from './ProjectManager';
+import dynamic from 'next/dynamic';
+
+const EmailManager = dynamic(
+ () => import('./EmailManager').then((m) => m.EmailManager),
+ { ssr: false, loading: () => Loading emails…
}
+);
+const AnalyticsDashboard = dynamic(
+ () => import('./AnalyticsDashboard').then((m) => m.default),
+ { ssr: false, loading: () => Loading analytics…
}
+);
+const ImportExport = dynamic(
+ () => import('./ImportExport').then((m) => m.default),
+ { ssr: false, loading: () => Loading tools…
}
+);
+const ProjectManager = dynamic(
+ () => import('./ProjectManager').then((m) => m.ProjectManager),
+ { ssr: false, loading: () => Loading projects…
}
+);
+const ContentManager = dynamic(
+ () => import('./ContentManager').then((m) => m.default),
+ { ssr: false, loading: () => Loading content…
}
+);
interface Project {
id: string;
@@ -52,7 +70,7 @@ interface ModernAdminDashboardProps {
}
const ModernAdminDashboard: React.FC = ({ isAuthenticated = true }) => {
- const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'emails' | 'analytics' | 'settings'>('overview');
+ const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings'>('overview');
const [projects, setProjects] = useState([]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isLoading, setIsLoading] = useState(false);
@@ -178,15 +196,31 @@ const ModernAdminDashboard: React.FC = ({ isAuthentic
};
useEffect(() => {
- // Load all data (authentication disabled)
- loadAllData();
- }, [loadAllData]);
+ // Prioritize the data needed for the initial dashboard render
+ void (async () => {
+ await Promise.all([loadProjects(), loadSystemStats()]);
+
+ const idle = (cb: () => void) => {
+ if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
+ (window as unknown as { requestIdleCallback: (fn: () => void) => void }).requestIdleCallback(cb);
+ } else {
+ setTimeout(cb, 300);
+ }
+ };
+
+ idle(() => {
+ void loadAnalytics();
+ void loadEmails();
+ });
+ })();
+ }, [loadProjects, loadSystemStats, loadAnalytics, loadEmails]);
const navigation = [
{ id: 'overview', label: 'Dashboard', icon: Home, color: 'blue', description: 'Overview & Statistics' },
{ id: 'projects', label: 'Projects', icon: Database, color: 'green', description: 'Manage Projects' },
{ id: 'emails', label: 'Emails', icon: Mail, color: 'purple', description: 'Email Management' },
{ id: 'analytics', label: 'Analytics', icon: Activity, color: 'orange', description: 'Site Analytics' },
+ { id: 'content', label: 'Content', icon: Shield, color: 'teal', description: 'Texts, pages & localization' },
{ id: 'settings', label: 'Settings', icon: Settings, color: 'gray', description: 'System Settings' }
];
@@ -221,7 +255,7 @@ const ModernAdminDashboard: React.FC = ({ isAuthentic
{navigation.map((item) => (
setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'settings')}
+ onClick={() => setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings')}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 ${
activeTab === item.id
? 'bg-stone-100 text-stone-900 font-medium shadow-sm border border-stone-200'
@@ -285,7 +319,7 @@ const ModernAdminDashboard: React.FC = ({ isAuthentic
{
- setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'settings');
+ setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings');
setMobileMenuOpen(false);
}}
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200 ${
@@ -590,6 +624,10 @@ const ModernAdminDashboard: React.FC = ({ isAuthentic
)}
+ {activeTab === 'content' && (
+
+ )}
+
{activeTab === 'settings' && (
diff --git a/components/ProjectManager.tsx b/components/ProjectManager.tsx
index 86fca66..1c7eee5 100644
--- a/components/ProjectManager.tsx
+++ b/components/ProjectManager.tsx
@@ -80,10 +80,12 @@ export const ProjectManager: React.FC
= ({
if (!confirm('Are you sure you want to delete this project?')) return;
try {
+ const sessionToken = sessionStorage.getItem('admin_session_token') || '';
await fetch(`/api/projects/${projectId}`, {
method: 'DELETE',
headers: {
- 'x-admin-request': 'true'
+ 'x-admin-request': 'true',
+ 'x-session-token': sessionToken
}
});
onProjectsChange();
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
index 63165fb..6ab4a32 100644
--- a/docker-compose.production.yml
+++ b/docker-compose.production.yml
@@ -18,6 +18,9 @@ services:
- MY_PASSWORD=${MY_PASSWORD}
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:your_secure_password_here}
+ - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
+ # If you already have an existing DB (pre-migrations), set this to true ONCE to baseline.
+ - PRISMA_AUTO_BASELINE=${PRISMA_AUTO_BASELINE:-false}
- LOG_LEVEL=info
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
diff --git a/docker-compose.testing.yml b/docker-compose.testing.yml
new file mode 100644
index 0000000..882ee66
--- /dev/null
+++ b/docker-compose.testing.yml
@@ -0,0 +1,97 @@
+# Testing Docker Compose configuration for testing.dk0.dev
+# Runs alongside production with isolated DB/Redis and different ports.
+
+services:
+ portfolio-testing:
+ image: portfolio-app:testing
+ container_name: portfolio-app-testing
+ restart: unless-stopped
+ ports:
+ - "3002:3000" # Nginx Proxy Manager -> http://HOST:3002
+ environment:
+ - NODE_ENV=production
+ - DATABASE_URL=postgresql://portfolio_user:portfolio_testing_pass@postgres-testing:5432/portfolio_testing_db?schema=public
+ - REDIS_URL=redis://redis-testing:6379
+ - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://testing.dk0.dev}
+ - MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
+ - MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
+ - MY_PASSWORD=${MY_PASSWORD}
+ - MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
+ - ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:testing_password}
+ - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
+ - PRISMA_AUTO_BASELINE=${PRISMA_AUTO_BASELINE:-false}
+ - LOG_LEVEL=info
+ - N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
+ - N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
+ - N8N_API_KEY=${N8N_API_KEY:-}
+ volumes:
+ - portfolio_testing_data:/app/.next/cache
+ networks:
+ - portfolio_testing_net
+ - proxy
+ depends_on:
+ postgres-testing:
+ condition: service_healthy
+ redis-testing:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+
+ postgres-testing:
+ image: postgres:16-alpine
+ container_name: portfolio-postgres-testing
+ restart: unless-stopped
+ environment:
+ - POSTGRES_DB=portfolio_testing_db
+ - POSTGRES_USER=portfolio_user
+ - POSTGRES_PASSWORD=portfolio_testing_pass
+ volumes:
+ - postgres_testing_data:/var/lib/postgresql/data
+ - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
+ networks:
+ - portfolio_testing_net
+ ports:
+ - "5435:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_testing_db"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+
+ redis-testing:
+ image: redis:7-alpine
+ container_name: portfolio-redis-testing
+ restart: unless-stopped
+ command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
+ volumes:
+ - redis_testing_data:/data
+ networks:
+ - portfolio_testing_net
+ ports:
+ - "6382:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+
+volumes:
+ portfolio_testing_data:
+ driver: local
+ postgres_testing_data:
+ driver: local
+ redis_testing_data:
+ driver: local
+
+networks:
+ portfolio_testing_net:
+ driver: bridge
+ proxy:
+ external: true
+
diff --git a/docs/CMS_GUIDE.md b/docs/CMS_GUIDE.md
new file mode 100644
index 0000000..3632fec
--- /dev/null
+++ b/docs/CMS_GUIDE.md
@@ -0,0 +1,20 @@
+# CMS Guide (ohne extra Software)
+
+Du brauchst **kein externes CMS**: das Projekt hat ein eingebautes, self-hosted CMS (Postgres + Admin UI).
+
+## Wo ist das CMS?
+
+- Öffne: `/manage`
+- Login (Admin)
+- Tab: **Content**
+
+## Wie bearbeite ich Texte?
+
+Im Content Tab kannst du auswählen:
+- **Page key** (z.B. `home-hero`, `home-about`, `home-contact`, `privacy-policy`, `legal-notice`)
+- **Locale** (`en` oder `de`)
+
+Dann:
+- Text bearbeiten (Rich Text)
+- **Save**
+
diff --git a/docs/TESTING_AND_DEPLOYMENT.md b/docs/TESTING_AND_DEPLOYMENT.md
new file mode 100644
index 0000000..93fdcdc
--- /dev/null
+++ b/docs/TESTING_AND_DEPLOYMENT.md
@@ -0,0 +1,69 @@
+# Testing & Deployment (Gitea → Docker → Nginx Proxy Manager)
+
+## Ziel
+
+- **Production**: Branch `production` → Container `portfolio-app` → `dk0.dev` (Port `3000`)
+- **Testing**: Branch `testing` → Container `portfolio-app-testing` → `testing.dk0.dev` (Port `3002`)
+
+Beide Stacks laufen parallel und sind komplett getrennt (eigene Postgres/Redis/Volumes).
+
+## DNS / Nginx Proxy Manager
+
+### DNS
+- Setze `A` (oder `CNAME`) Records:
+ - `dk0.dev` → dein Server
+ - `testing.dk0.dev` → dein Server
+
+### Nginx Proxy Manager
+Lege zwei Proxy Hosts an:
+
+- **`dk0.dev`**
+ - Forward Hostname/IP: `127.0.0.1` (oder Server-IP)
+ - Forward Port: `3000`
+
+- **`testing.dk0.dev`**
+ - Forward Hostname/IP: `127.0.0.1` (oder Server-IP)
+ - Forward Port: `3002`
+
+Dann SSL Zertifikate (Let’s Encrypt) aktivieren.
+
+## Gitea Workflows
+
+- `production` push → `.gitea/workflows/production-deploy.yml`
+- `testing` push → `.gitea/workflows/dev-deploy.yml` (umbenannt im Namen, Inhalt ist Testing)
+
+### Benötigte Variables (Gitea)
+- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
+- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev`
+- optional: `MY_EMAIL`, `MY_INFO_EMAIL`, `LOG_LEVEL`, `N8N_WEBHOOK_URL`, `N8N_API_KEY`
+
+### Benötigte Secrets (Gitea)
+- `MY_PASSWORD`
+- `MY_INFO_PASSWORD`
+- `ADMIN_BASIC_AUTH` (z.B. `admin:`)
+- `ADMIN_SESSION_SECRET` (mind. 32 Zeichen, zufällig; für Session-Login im Admin)
+- optional: `N8N_SECRET_TOKEN`
+
+## Docker Compose Files
+
+- Production: `docker-compose.production.yml` (Port 3000)
+- Testing: `docker-compose.testing.yml` (Port 3002)
+
+Wenn du “dev” nicht mehr brauchst, kannst du den Branch einfach nicht mehr benutzen.
+
+## Prisma Migrations (Auto-Deploy)
+
+Der App-Container führt beim Start automatisch aus:
+- `prisma migrate deploy`
+
+### Wichtig: bestehende Datenbank (Baseline)
+Wenn deine DB bereits existiert (vor Einführung von Prisma Migrations), dann würde die initiale Migration sonst mit “table already exists” scheitern.
+
+**Einmalig beim ersten Deploy**:
+- Setze `PRISMA_AUTO_BASELINE=true` (z.B. als Compose env oder Gitea Variable/Secret)
+- Deploy ausführen
+- Danach wieder auf `false` setzen
+
+Alternative (manuell/sauber):
+- Baseline per `prisma migrate resolve --applied ` ausführen (z.B. lokal gegen die Prod-DB)
+
diff --git a/e2e/activity-feed.spec.ts b/e2e/activity-feed.spec.ts
new file mode 100644
index 0000000..8223031
--- /dev/null
+++ b/e2e/activity-feed.spec.ts
@@ -0,0 +1,32 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("ActivityFeed reload rendering", () => {
+ test("feed stays visible and dark after reload", async ({ page }) => {
+ await page.goto("/en", { waitUntil: "domcontentloaded" });
+
+ const feed = page.locator('[class*="bg-black/95"]').filter({ hasText: "Live Activity" }).first();
+ await expect(feed).toBeVisible({ timeout: 15000 });
+
+ const initialBox = await feed.boundingBox();
+ expect(initialBox).not.toBeNull();
+ expect(initialBox!.width).toBeGreaterThan(200);
+ expect(initialBox!.height).toBeGreaterThan(30);
+
+ const initialOpacity = await feed.evaluate((el) => getComputedStyle(el).opacity);
+ expect(Number(initialOpacity)).toBeGreaterThan(0.5);
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+
+ const feedAfter = page.locator('[class*="bg-black/95"]').filter({ hasText: "Live Activity" }).first();
+ await expect(feedAfter).toBeVisible({ timeout: 15000 });
+
+ const afterBox = await feedAfter.boundingBox();
+ expect(afterBox).not.toBeNull();
+ expect(afterBox!.width).toBeGreaterThan(200);
+ expect(afterBox!.height).toBeGreaterThan(30);
+
+ const afterOpacity = await feedAfter.evaluate((el) => getComputedStyle(el).opacity);
+ expect(Number(afterOpacity)).toBeGreaterThan(0.5);
+ });
+});
+
diff --git a/e2e/consent.spec.ts b/e2e/consent.spec.ts
new file mode 100644
index 0000000..f3adc4f
--- /dev/null
+++ b/e2e/consent.spec.ts
@@ -0,0 +1,27 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Consent banner", () => {
+ test("banner shows and can be accepted", async ({ page, context }) => {
+ // Start clean
+ await context.clearCookies();
+
+ await page.goto("/en", { waitUntil: "domcontentloaded" });
+
+ // Banner should appear on public pages when no consent is set yet
+ const bannerTitle = page.getByText(/Privacy settings|Datenschutz-Einstellungen/i);
+ await expect(bannerTitle).toBeVisible({ timeout: 10000 });
+
+ // Accept all
+ const acceptAll = page.getByRole("button", { name: /Accept all|Alles akzeptieren/i });
+ await acceptAll.click();
+
+ // Banner disappears
+ await expect(bannerTitle).toBeHidden({ timeout: 10000 });
+
+ // Cookie is written
+ const cookies = await context.cookies();
+ const consentCookie = cookies.find((c) => c.name === "dk0_consent_v1");
+ expect(consentCookie).toBeTruthy();
+ });
+});
+
diff --git a/e2e/critical-paths.spec.ts b/e2e/critical-paths.spec.ts
index 9fdee8c..ead8788 100644
--- a/e2e/critical-paths.spec.ts
+++ b/e2e/critical-paths.spec.ts
@@ -6,7 +6,7 @@ import { test, expect } from '@playwright/test';
*/
test.describe('Critical Paths', () => {
test('Home page loads and displays correctly', async ({ page }) => {
- await page.goto('/', { waitUntil: 'networkidle' });
+ await page.goto('/en', { waitUntil: 'networkidle' });
// Wait for page to be fully loaded
await page.waitForLoadState('domcontentloaded');
@@ -25,7 +25,7 @@ test.describe('Critical Paths', () => {
});
test('Projects page loads and displays projects', async ({ page }) => {
- await page.goto('/projects', { waitUntil: 'networkidle' });
+ await page.goto('/en/projects', { waitUntil: 'networkidle' });
// Wait for projects to load
await page.waitForLoadState('domcontentloaded');
@@ -45,7 +45,7 @@ test.describe('Critical Paths', () => {
test('Individual project page loads', async ({ page }) => {
// First, get a project slug from the projects page
- await page.goto('/projects', { waitUntil: 'networkidle' });
+ await page.goto('/en/projects', { waitUntil: 'networkidle' });
await page.waitForLoadState('domcontentloaded');
// Try to find a project link
diff --git a/e2e/hydration.spec.ts b/e2e/hydration.spec.ts
index 5221054..3522bfb 100644
--- a/e2e/hydration.spec.ts
+++ b/e2e/hydration.spec.ts
@@ -20,7 +20,7 @@ test.describe('Hydration Tests', () => {
});
// Navigate to home page
- await page.goto('/', { waitUntil: 'networkidle' });
+ await page.goto('/en', { waitUntil: 'networkidle' });
await page.waitForLoadState('domcontentloaded');
// Check for hydration errors
@@ -51,7 +51,7 @@ test.describe('Hydration Tests', () => {
}
});
- await page.goto('/');
+ await page.goto('/en');
await page.waitForLoadState('networkidle');
// Check for duplicate key warnings
@@ -71,11 +71,11 @@ test.describe('Hydration Tests', () => {
}
});
- await page.goto('/', { waitUntil: 'networkidle' });
+ await page.goto('/en', { waitUntil: 'networkidle' });
await page.waitForLoadState('domcontentloaded');
// Navigate to projects page via link
- const projectsLink = page.locator('a[href="/projects"], a[href*="projects"]').first();
+ const projectsLink = page.locator('a[href*="/projects"]').first();
if (await projectsLink.count() > 0) {
await projectsLink.click();
await page.waitForLoadState('domcontentloaded');
@@ -90,7 +90,7 @@ test.describe('Hydration Tests', () => {
});
test('Server and client HTML match', async ({ page }) => {
- await page.goto('/');
+ await page.goto('/en');
// Get initial HTML
const initialHTML = await page.content();
@@ -108,7 +108,7 @@ test.describe('Hydration Tests', () => {
});
test('Interactive elements work after hydration', async ({ page }) => {
- await page.goto('/');
+ await page.goto('/en');
await page.waitForLoadState('networkidle');
// Try to find and click interactive elements
diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts
new file mode 100644
index 0000000..d7f069c
--- /dev/null
+++ b/e2e/i18n.spec.ts
@@ -0,0 +1,25 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("i18n routing", () => {
+ test("language switcher navigates between locales", async ({ page }) => {
+ await page.goto("/en", { waitUntil: "domcontentloaded" });
+
+ // Locale switchers are links (work even without hydration)
+ const deLink = page.getByRole("link", { name: "Sprache auf Deutsch umstellen" });
+ if (await deLink.count()) {
+ // Verify an EN label is present before switching (nav.home)
+ await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
+
+ await Promise.all([
+ page.waitForURL(/\/de(\/|$)/, { timeout: 30000 }),
+ deLink.click(),
+ ]);
+
+ // Verify the nav label updates after switching
+ await expect(page.getByRole("link", { name: "Start" })).toBeVisible();
+ } else {
+ test.skip();
+ }
+ });
+});
+
diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts
new file mode 100644
index 0000000..d1b85e3
--- /dev/null
+++ b/e2e/seo.spec.ts
@@ -0,0 +1,22 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("SEO endpoints", () => {
+ test("robots.txt is served and contains sitemap", async ({ request }) => {
+ const res = await request.get("/robots.txt");
+ expect(res.ok()).toBeTruthy();
+ const txt = await res.text();
+ expect(txt).toContain("User-agent:");
+ expect(txt).toContain("Sitemap:");
+ });
+
+ test("sitemap.xml is served and contains locale routes", async ({ request }) => {
+ const res = await request.get("/sitemap.xml");
+ expect(res.ok()).toBeTruthy();
+ const xml = await res.text();
+ expect(xml).toContain('');
+ // At least the localized home routes should exist
+ expect(xml).toMatch(/\/en<\/loc>/);
+ expect(xml).toMatch(/\/de<\/loc>/);
+ });
+});
+
diff --git a/env.example b/env.example
index cec1add..6a3efd5 100644
--- a/env.example
+++ b/env.example
@@ -34,6 +34,14 @@ N8N_API_KEY=your-n8n-api-key
# JWT_SECRET=your-jwt-secret
# ENCRYPTION_KEY=your-encryption-key
ADMIN_BASIC_AUTH=admin:your_secure_password_here
+ADMIN_SESSION_SECRET=change_me_to_a_long_random_string_at_least_32_chars
+
+# Prisma migrations at container startup
+# - default: migrations are executed (`prisma migrate deploy`)
+# - set to true ONCE if you already have an existing DB that was created before migrations existed
+PRISMA_AUTO_BASELINE=false
+# emergency switch (not recommended for normal operation)
+# SKIP_PRISMA_MIGRATE=true
# Monitoring (optional)
# SENTRY_DSN=your-sentry-dsn
diff --git a/i18n/locales.ts b/i18n/locales.ts
new file mode 100644
index 0000000..c2abae3
--- /dev/null
+++ b/i18n/locales.ts
@@ -0,0 +1,3 @@
+export const locales = ["en", "de"] as const;
+export type AppLocale = (typeof locales)[number];
+
diff --git a/i18n/request.ts b/i18n/request.ts
new file mode 100644
index 0000000..cf82189
--- /dev/null
+++ b/i18n/request.ts
@@ -0,0 +1,15 @@
+import { getRequestConfig } from "next-intl/server";
+import { locales } from "./locales";
+export { locales, type AppLocale } from "./locales";
+
+export default getRequestConfig(async ({ locale }) => {
+ // next-intl can call us with unknown/undefined locales; fall back safely
+ const requested = typeof locale === "string" ? locale : "en";
+ const safeLocale = (locales as readonly string[]).includes(requested) ? requested : "en";
+
+ return {
+ locale: safeLocale,
+ messages: (await import(`../messages/${safeLocale}.json`)).default,
+ };
+});
+
diff --git a/jest.setup.ts b/jest.setup.ts
index f9c6ae8..ddcec48 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -26,6 +26,69 @@ jest.mock("next/navigation", () => ({
notFound: jest.fn(),
}));
+// Mock next-intl (ESM) for Jest
+jest.mock("next-intl", () => ({
+ useLocale: () => "en",
+ useTranslations:
+ (namespace?: string) =>
+ (key: string) => {
+ if (namespace === "nav") {
+ const map: Record = {
+ home: "Home",
+ about: "About",
+ projects: "Projects",
+ contact: "Contact",
+ };
+ return map[key] || key;
+ }
+ if (namespace === "common") {
+ const map: Record = {
+ backToHome: "Back to Home",
+ backToProjects: "Back to Projects",
+ };
+ return map[key] || key;
+ }
+ if (namespace === "home.hero") {
+ const map: Record = {
+ "features.f1": "Next.js & Flutter",
+ "features.f2": "Docker Swarm & CI/CD",
+ "features.f3": "Self-Hosted Infrastructure",
+ description:
+ "Student and passionate self-hoster building full-stack web apps and mobile solutions. I run my own infrastructure and love exploring DevOps.",
+ ctaWork: "View My Work",
+ ctaContact: "Contact Me",
+ };
+ return map[key] || key;
+ }
+ if (namespace === "home.about") {
+ const map: Record = {
+ title: "About Me",
+ p1: "Hi, I'm Dennis – a student and passionate self-hoster based in Osnabrück, Germany.",
+ p2: "I love building full-stack web applications with Next.js and mobile apps with Flutter. But what really excites me is DevOps: I run my own infrastructure and automate deployments with CI/CD.",
+ p3: "When I'm not coding or tinkering with servers, you'll find me gaming, jogging, or experimenting with automation workflows.",
+ funFactTitle: "Fun Fact",
+ funFactBody:
+ "Even though I automate a lot, I still use pen and paper for my calendar and notes – it helps me stay focused.",
+ };
+ return map[key] || key;
+ }
+ if (namespace === "home.contact") {
+ const map: Record = {
+ title: "Contact Me",
+ subtitle:
+ "Interested in working together or have questions about my projects? Feel free to reach out!",
+ getInTouch: "Get In Touch",
+ getInTouchBody:
+ "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
+ };
+ return map[key] || key;
+ }
+ return key;
+ },
+ NextIntlClientProvider: ({ children }: { children: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+}));
+
// Mock next/link
jest.mock("next/link", () => {
return function Link({
diff --git a/lib/analytics.ts b/lib/analytics.ts
index e6975b9..35eba8c 100644
--- a/lib/analytics.ts
+++ b/lib/analytics.ts
@@ -25,12 +25,21 @@ export interface WebVitalsMetric {
// Track custom events to Umami
export const trackEvent = (event: string, data?: Record) => {
- if (typeof window !== 'undefined' && window.umami) {
- window.umami.track(event, {
+ if (typeof window === "undefined") return;
+ const trackFn = window.umami?.track;
+ if (typeof trackFn !== "function") return;
+
+ try {
+ trackFn(event, {
...data,
timestamp: Date.now(),
url: window.location.pathname,
});
+ } catch (error) {
+ // Silently fail - analytics must never break the app
+ if (process.env.NODE_ENV === "development") {
+ console.warn("Error tracking Umami event:", error);
+ }
}
};
diff --git a/lib/auth.ts b/lib/auth.ts
index 49c550e..16ee213 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -1,4 +1,117 @@
import { NextRequest } from 'next/server';
+import crypto from 'crypto';
+
+const DEFAULT_INSECURE_ADMIN = 'admin:default_password_change_me';
+const SESSION_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+function base64UrlEncode(input: string | Buffer): string {
+ const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
+}
+
+function base64UrlDecodeToString(input: string): string {
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
+ return Buffer.from(normalized + pad, 'base64').toString('utf8');
+}
+
+function base64UrlDecodeToBuffer(input: string): Buffer {
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
+ return Buffer.from(normalized + pad, 'base64');
+}
+
+export function getClientIp(request: NextRequest): string {
+ const xff = request.headers.get('x-forwarded-for');
+ if (xff) {
+ // x-forwarded-for can be a list: client, proxy1, proxy2
+ return xff.split(',')[0]?.trim() || 'unknown';
+ }
+ return request.headers.get('x-real-ip') || 'unknown';
+}
+
+function getAdminCredentials(): { username: string; password: string } | null {
+ const raw = process.env.ADMIN_BASIC_AUTH;
+ if (!raw || raw.trim() === '' || raw === DEFAULT_INSECURE_ADMIN) return null;
+ const idx = raw.indexOf(':');
+ if (idx <= 0 || idx === raw.length - 1) return null;
+ return { username: raw.slice(0, idx), password: raw.slice(idx + 1) };
+}
+
+function getSessionSecret(): string | null {
+ const secret = process.env.ADMIN_SESSION_SECRET;
+ if (!secret || secret.trim().length < 32) return null; // require a reasonably strong secret
+ return secret;
+}
+
+type SessionPayload = {
+ v: 1;
+ iat: number;
+ rnd: string;
+ ip: string;
+ ua: string;
+};
+
+export function createSessionToken(request: NextRequest): string | null {
+ const secret = getSessionSecret();
+ if (!secret) return null;
+
+ const payload: SessionPayload = {
+ v: 1,
+ iat: Date.now(),
+ rnd: crypto.randomBytes(32).toString('hex'),
+ ip: getClientIp(request),
+ ua: request.headers.get('user-agent') || 'unknown',
+ };
+
+ const payloadB64 = base64UrlEncode(JSON.stringify(payload));
+ const sig = crypto.createHmac('sha256', secret).update(payloadB64).digest();
+ const sigB64 = base64UrlEncode(sig);
+ return `${payloadB64}.${sigB64}`;
+}
+
+export function verifySessionToken(request: NextRequest, token: string): boolean {
+ const secret = getSessionSecret();
+ if (!secret) return false;
+
+ const parts = token.split('.');
+ if (parts.length !== 2) return false;
+ const [payloadB64, sigB64] = parts;
+ if (!payloadB64 || !sigB64) return false;
+
+ let providedSigBytes: Buffer;
+ try {
+ providedSigBytes = base64UrlDecodeToBuffer(sigB64);
+ } catch {
+ return false;
+ }
+
+ const expectedSigBytes = crypto.createHmac('sha256', secret).update(payloadB64).digest();
+ if (providedSigBytes.length !== expectedSigBytes.length) return false;
+ if (!crypto.timingSafeEqual(providedSigBytes, expectedSigBytes)) return false;
+
+ let payload: SessionPayload;
+ try {
+ payload = JSON.parse(base64UrlDecodeToString(payloadB64)) as SessionPayload;
+ } catch {
+ return false;
+ }
+
+ if (!payload || payload.v !== 1 || typeof payload.iat !== 'number' || typeof payload.rnd !== 'string') {
+ return false;
+ }
+
+ const now = Date.now();
+ if (now - payload.iat > SESSION_DURATION_MS) return false;
+
+ // Bind token to client IP + UA (best-effort; "unknown" should not hard-fail)
+ const currentIp = getClientIp(request);
+ const currentUa = request.headers.get('user-agent') || 'unknown';
+ if (payload.ip !== 'unknown' && currentIp !== 'unknown' && payload.ip !== currentIp) return false;
+ if (payload.ua !== 'unknown' && currentUa !== 'unknown' && payload.ua !== currentUa) return false;
+
+ return true;
+}
// Server-side authentication utilities
export function verifyAdminAuth(request: NextRequest): boolean {
@@ -11,14 +124,14 @@ export function verifyAdminAuth(request: NextRequest): boolean {
try {
const base64Credentials = authHeader.split(' ')[1];
- const credentials = atob(base64Credentials);
+ const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8');
const [username, password] = credentials.split(':');
// Get admin credentials from environment
- const adminAuth = process.env.ADMIN_BASIC_AUTH || 'admin:default_password_change_me';
- const [expectedUsername, expectedPassword] = adminAuth.split(':');
+ const creds = getAdminCredentials();
+ if (!creds) return false;
- return username === expectedUsername && password === expectedPassword;
+ return username === creds.username && password === creds.password;
} catch {
return false;
}
@@ -46,31 +159,7 @@ export function verifySessionAuth(request: NextRequest): boolean {
if (!sessionToken) return false;
try {
- // Decode and validate session token
- const decodedJson = atob(sessionToken);
- const sessionData = JSON.parse(decodedJson);
-
- // Validate session data structure
- if (!sessionData.timestamp || !sessionData.random || !sessionData.ip || !sessionData.userAgent) {
- return false;
- }
-
- // Check if session is still valid (2 hours)
- const sessionTime = sessionData.timestamp;
- const now = Date.now();
- const sessionDuration = 2 * 60 * 60 * 1000; // 2 hours
-
- if (now - sessionTime > sessionDuration) {
- return false;
- }
-
- // Validate IP address (optional, but good security practice)
- const currentIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
- if (sessionData.ip !== currentIp) {
- return false;
- }
-
- return true;
+ return verifySessionToken(request, sessionToken);
} catch {
return false;
}
diff --git a/lib/cache.ts b/lib/cache.ts
index aaeac03..57e1bd3 100644
--- a/lib/cache.ts
+++ b/lib/cache.ts
@@ -10,8 +10,9 @@ export const apiCache = {
if (page !== '1') keyParts.push(`page:${page}`);
if (limit !== '50') keyParts.push(`limit:${limit}`);
if (category) keyParts.push(`cat:${category}`);
- if (featured !== null) keyParts.push(`feat:${featured}`);
- if (published !== null) keyParts.push(`pub:${published}`);
+ // Avoid cache fragmentation like `feat:undefined` when params omit the field
+ if (featured != null) keyParts.push(`feat:${featured}`);
+ if (published != null) keyParts.push(`pub:${published}`);
if (difficulty) keyParts.push(`diff:${difficulty}`);
if (search) keyParts.push(`search:${search}`);
diff --git a/lib/content.ts b/lib/content.ts
new file mode 100644
index 0000000..5521b0d
--- /dev/null
+++ b/lib/content.ts
@@ -0,0 +1,82 @@
+import { prisma } from "@/lib/prisma";
+import type { Prisma } from "@prisma/client";
+import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
+
+export async function getSiteSettings() {
+ return prisma.siteSettings.findUnique({ where: { id: 1 } });
+}
+
+export async function getContentByKey(opts: { key: string; locale: string }) {
+ const { key, locale } = opts;
+ try {
+ const page = await prisma.contentPage.findUnique({
+ where: { key },
+ include: {
+ translations: {
+ where: { locale },
+ take: 1,
+ },
+ },
+ });
+
+ if (page?.translations?.[0]) return page.translations[0];
+
+ const settings = await getSiteSettings();
+ const fallbackLocale = settings?.defaultLocale || "en";
+
+ const fallback = await prisma.contentPageTranslation.findFirst({
+ where: {
+ page: { key },
+ locale: fallbackLocale,
+ },
+ });
+
+ return fallback;
+ } catch (error) {
+ // If migrations haven't been applied yet, don't crash the app.
+ // Let callers fall back to static translations.
+ if (error instanceof PrismaClientKnownRequestError && (error.code === "P2021" || error.code === "P2022")) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+export async function upsertContentByKey(opts: {
+ key: string;
+ locale: string;
+ title?: string | null;
+ slug?: string | null;
+ content: unknown;
+ metaDescription?: string | null;
+ keywords?: string | null;
+}) {
+ const { key, locale, title, slug, content, metaDescription, keywords } = opts;
+
+ const page = await prisma.contentPage.upsert({
+ where: { key },
+ create: { key, status: "PUBLISHED" },
+ update: {},
+ });
+
+ return prisma.contentPageTranslation.upsert({
+ where: { pageId_locale: { pageId: page.id, locale } },
+ create: {
+ pageId: page.id,
+ locale,
+ title: title ?? undefined,
+ slug: slug ?? undefined,
+ content: content as Prisma.InputJsonValue, // JSON
+ metaDescription: metaDescription ?? undefined,
+ keywords: keywords ?? undefined,
+ },
+ update: {
+ title: title ?? undefined,
+ slug: slug ?? undefined,
+ content: content as Prisma.InputJsonValue, // JSON
+ metaDescription: metaDescription ?? undefined,
+ keywords: keywords ?? undefined,
+ },
+ });
+}
+
diff --git a/lib/prisma.ts b/lib/prisma.ts
index e9c7e8d..f75419b 100644
--- a/lib/prisma.ts
+++ b/lib/prisma.ts
@@ -1,4 +1,5 @@
import { PrismaClient } from '@prisma/client';
+import { generateUniqueSlug } from './slug';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
@@ -68,9 +69,26 @@ export const projectService = {
// Create new project
async createProject(data: Record) {
+ const providedSlug = typeof data.slug === 'string' ? data.slug : undefined;
+ const providedTitle = typeof data.title === 'string' ? data.title : undefined;
+
+ const slug =
+ providedSlug?.trim() ||
+ (await generateUniqueSlug({
+ base: providedTitle || 'project',
+ isTaken: async (candidate) => {
+ const existing = await prisma.project.findUnique({
+ where: { slug: candidate },
+ select: { id: true },
+ });
+ return !!existing;
+ },
+ }));
+
return prisma.project.create({
data: {
...data,
+ slug,
performance: data.performance || { lighthouse: 0, bundleSize: '0KB', loadTime: '0s' },
analytics: data.analytics || { views: 0, likes: 0, shares: 0 }
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -159,14 +177,16 @@ export const projectService = {
prisma.userInteraction.groupBy({
by: ['type'],
where: { projectId },
+ _count: { _all: true },
})
]);
const analytics: Record = { views: pageViews, likes: 0, shares: 0 };
interactions.forEach(interaction => {
- if (interaction.type === 'LIKE') analytics.likes = 0;
- if (interaction.type === 'SHARE') analytics.shares = 0;
+ const count = (interaction as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
+ if (interaction.type === 'LIKE') analytics.likes = count;
+ if (interaction.type === 'SHARE') analytics.shares = count;
});
return analytics;
diff --git a/lib/richtext.ts b/lib/richtext.ts
new file mode 100644
index 0000000..21ee2fb
--- /dev/null
+++ b/lib/richtext.ts
@@ -0,0 +1,71 @@
+import sanitizeHtml from "sanitize-html";
+import type { JSONContent } from "@tiptap/react";
+import { generateHTML } from "@tiptap/html";
+import StarterKit from "@tiptap/starter-kit";
+import Underline from "@tiptap/extension-underline";
+import Link from "@tiptap/extension-link";
+import { TextStyle } from "@tiptap/extension-text-style";
+import Color from "@tiptap/extension-color";
+import Highlight from "@tiptap/extension-highlight";
+import { FontFamily } from "@/lib/tiptap/fontFamily";
+
+export function richTextToSafeHtml(doc: JSONContent): string {
+ const raw = generateHTML(doc, [
+ StarterKit,
+ Underline,
+ Link.configure({
+ openOnClick: false,
+ autolink: false,
+ HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
+ }),
+ TextStyle,
+ FontFamily,
+ Color,
+ Highlight,
+ ]);
+
+ return sanitizeHtml(raw, {
+ allowedTags: [
+ "p",
+ "br",
+ "h1",
+ "h2",
+ "h3",
+ "blockquote",
+ "strong",
+ "em",
+ "u",
+ "a",
+ "ul",
+ "ol",
+ "li",
+ "code",
+ "pre",
+ "span"
+ ],
+ allowedAttributes: {
+ a: ["href", "rel", "target"],
+ span: ["style"],
+ code: ["class"],
+ pre: ["class"],
+ p: ["class"],
+ h1: ["class"],
+ h2: ["class"],
+ h3: ["class"],
+ blockquote: ["class"],
+ ul: ["class"],
+ ol: ["class"],
+ li: ["class"]
+ },
+ allowedSchemes: ["http", "https", "mailto"],
+ allowProtocolRelative: false,
+ allowedStyles: {
+ span: {
+ color: [/^#[0-9a-fA-F]{3,8}$/],
+ "background-color": [/^#[0-9a-fA-F]{3,8}$/],
+ "font-family": [/^(Inter|ui-sans-serif|ui-serif|ui-monospace)$/],
+ },
+ },
+ });
+}
+
diff --git a/lib/seo.ts b/lib/seo.ts
new file mode 100644
index 0000000..d153ec4
--- /dev/null
+++ b/lib/seo.ts
@@ -0,0 +1,30 @@
+import { locales, type AppLocale } from "@/i18n/locales";
+
+export function getBaseUrl(): string {
+ const raw =
+ process.env.NEXT_PUBLIC_BASE_URL ||
+ process.env.NEXTAUTH_URL || // fallback if ever added
+ "http://localhost:3000";
+ return raw.replace(/\/+$/, "");
+}
+
+export function toAbsoluteUrl(path: string): string {
+ const base = getBaseUrl();
+ const normalized = path.startsWith("/") ? path : `/${path}`;
+ return `${base}${normalized}`;
+}
+
+export function getLanguageAlternates(opts: {
+ /** Path without locale prefix, e.g. "/projects" or "/projects/my-slug" or "" */
+ pathWithoutLocale: string;
+}): Record {
+ const path = opts.pathWithoutLocale === "" ? "" : `/${opts.pathWithoutLocale}`.replace(/\/{2,}/g, "/");
+ const normalizedPath = path === "/" ? "" : path;
+
+ return locales.reduce((acc, l) => {
+ const url = toAbsoluteUrl(`/${l}${normalizedPath}`);
+ acc[l] = url;
+ return acc;
+ }, {} as Record);
+}
+
diff --git a/lib/sitemap.ts b/lib/sitemap.ts
new file mode 100644
index 0000000..4405283
--- /dev/null
+++ b/lib/sitemap.ts
@@ -0,0 +1,70 @@
+import { prisma } from "@/lib/prisma";
+import { locales } from "@/i18n/locales";
+import { getBaseUrl } from "@/lib/seo";
+
+export type SitemapEntry = {
+ url: string;
+ lastModified: string;
+ changefreq?: "daily" | "weekly" | "monthly" | "yearly";
+ priority?: number;
+};
+
+export function generateSitemapXml(entries: SitemapEntry[]): string {
+ const xmlHeader = '';
+ const urlsetOpen = '';
+ const urlsetClose = " ";
+
+ const urlEntries = entries
+ .map((e) => {
+ const changefreq = e.changefreq ?? "monthly";
+ const priority = typeof e.priority === "number" ? e.priority : 0.8;
+ return `
+
+ ${e.url}
+ ${e.lastModified}
+ ${changefreq}
+ ${priority.toFixed(1)}
+ `;
+ })
+ .join("");
+
+ return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`;
+}
+
+export async function getSitemapEntries(): Promise {
+ const baseUrl = getBaseUrl();
+ const nowIso = new Date().toISOString();
+
+ const staticPaths = ["", "/projects", "/legal-notice", "/privacy-policy"];
+ const staticEntries: SitemapEntry[] = locales.flatMap((locale) =>
+ staticPaths.map((p) => {
+ const path = p === "" ? `/${locale}` : `/${locale}${p}`;
+ return {
+ url: `${baseUrl}${path}`,
+ lastModified: nowIso,
+ changefreq: p === "" ? "weekly" : p === "/projects" ? "weekly" : "yearly",
+ priority: p === "" ? 1.0 : p === "/projects" ? 0.8 : 0.5,
+ };
+ }),
+ );
+
+ // Projects: for each project slug we publish per locale (same slug)
+ const projects = await prisma.project.findMany({
+ where: { published: true },
+ select: { slug: true, updatedAt: true },
+ orderBy: { updatedAt: "desc" },
+ });
+
+ const projectEntries: SitemapEntry[] = projects.flatMap((p) => {
+ const lastModified = (p.updatedAt ?? new Date()).toISOString();
+ return locales.map((locale) => ({
+ url: `${baseUrl}/${locale}/projects/${p.slug}`,
+ lastModified,
+ changefreq: "monthly",
+ priority: 0.7,
+ }));
+ });
+
+ return [...staticEntries, ...projectEntries];
+}
+
diff --git a/lib/slug.ts b/lib/slug.ts
new file mode 100644
index 0000000..fe360dd
--- /dev/null
+++ b/lib/slug.ts
@@ -0,0 +1,30 @@
+export function slugify(input: string): string {
+ return input
+ .trim()
+ .toLowerCase()
+ .replace(/['"]/g, "")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+export async function generateUniqueSlug(opts: {
+ base: string;
+ isTaken: (slug: string) => Promise;
+ maxAttempts?: number;
+}): Promise {
+ const maxAttempts = opts.maxAttempts ?? 50;
+ const normalizedBase = slugify(opts.base) || "item";
+
+ let candidate = normalizedBase;
+ for (let i = 0; i < maxAttempts; i++) {
+ // First try the base, then base-2, base-3, ...
+ candidate = i === 0 ? normalizedBase : `${normalizedBase}-${i + 1}`;
+ // eslint-disable-next-line no-await-in-loop
+ const taken = await opts.isTaken(candidate);
+ if (!taken) return candidate;
+ }
+
+ // Last resort: append timestamp to avoid collisions
+ return `${normalizedBase}-${Date.now()}`;
+}
+
diff --git a/lib/tiptap/fontFamily.ts b/lib/tiptap/fontFamily.ts
new file mode 100644
index 0000000..38dc4fc
--- /dev/null
+++ b/lib/tiptap/fontFamily.ts
@@ -0,0 +1,67 @@
+import { Extension } from "@tiptap/core";
+
+const allowedFonts = [
+ "Inter",
+ "ui-sans-serif",
+ "ui-serif",
+ "ui-monospace",
+] as const;
+
+export type AllowedFontFamily = (typeof allowedFonts)[number];
+
+declare module "@tiptap/core" {
+ interface Commands {
+ fontFamily: {
+ setFontFamily: (fontFamily: string) => ReturnType;
+ unsetFontFamily: () => ReturnType;
+ };
+ }
+}
+
+export const FontFamily = Extension.create({
+ name: "fontFamily",
+
+ addGlobalAttributes() {
+ return [
+ {
+ types: ["textStyle"],
+ attributes: {
+ fontFamily: {
+ default: null,
+ parseHTML: (element) => {
+ const raw = (element as HTMLElement).style.fontFamily;
+ if (!raw) return null;
+ // Normalize: remove quotes and take first family only
+ const first = raw.split(",")[0]?.trim().replace(/^["']|["']$/g, "");
+ if (!first) return null;
+ return first;
+ },
+ renderHTML: (attributes) => {
+ const fontFamily = attributes.fontFamily as string | null;
+ if (!fontFamily) return {};
+ if (!allowedFonts.includes(fontFamily as AllowedFontFamily)) return {};
+ return { style: `font-family: ${fontFamily}` };
+ },
+ },
+ },
+ },
+ ];
+ },
+
+ addCommands() {
+ return {
+ setFontFamily:
+ (fontFamily: string) =>
+ ({ chain }) => {
+ if (!allowedFonts.includes(fontFamily as AllowedFontFamily)) return false;
+ return chain().setMark("textStyle", { fontFamily }).run();
+ },
+ unsetFontFamily:
+ () =>
+ ({ chain }) => {
+ return chain().setMark("textStyle", { fontFamily: null }).removeEmptyTextStyle().run();
+ },
+ };
+ },
+});
+
diff --git a/lib/useWebVitals.ts b/lib/useWebVitals.ts
index 072ba11..b60bfee 100644
--- a/lib/useWebVitals.ts
+++ b/lib/useWebVitals.ts
@@ -208,6 +208,13 @@ export const useWebVitals = () => {
// Wrap everything in try-catch to prevent errors from breaking the app
try {
+ const safeNow = () => {
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
+ return performance.now();
+ }
+ return Date.now();
+ };
+
// Store web vitals for batch sending
const webVitals: Record = {};
const path = window.location.pathname;
@@ -233,7 +240,7 @@ export const useWebVitals = () => {
cls: webVitals.CLS || 0,
fid: webVitals.FID || 0,
ttfb: webVitals.TTFB || 0,
- loadTime: performance.now()
+ loadTime: safeNow()
}
})
});
@@ -307,7 +314,7 @@ export const useWebVitals = () => {
setTimeout(() => {
trackPerformance({
name: 'page-load-complete',
- value: performance.now(),
+ value: safeNow(),
url: window.location.pathname,
timestamp: Date.now(),
userAgent: navigator.userAgent,
diff --git a/messages/de.json b/messages/de.json
new file mode 100644
index 0000000..d02d101
--- /dev/null
+++ b/messages/de.json
@@ -0,0 +1,52 @@
+{
+ "nav": {
+ "home": "Start",
+ "about": "Über mich",
+ "projects": "Projekte",
+ "contact": "Kontakt"
+ },
+ "common": {
+ "backToHome": "Zurück zur Startseite",
+ "backToProjects": "Zurück zu den Projekten",
+ "viewAllProjects": "Alle Projekte ansehen",
+ "loading": "Lädt..."
+ },
+ "consent": {
+ "title": "Datenschutz-Einstellungen",
+ "description": "Wir nutzen optionale Dienste (Analytics und Chat), um die Seite zu verbessern. Du kannst deine Auswahl jederzeit ändern.",
+ "essential": "Essentiell",
+ "analytics": "Analytics",
+ "chat": "Chatbot",
+ "acceptAll": "Alles akzeptieren",
+ "acceptSelected": "Auswahl akzeptieren",
+ "rejectAll": "Alles ablehnen"
+ }
+ ,
+ "home": {
+ "hero": {
+ "features": {
+ "f1": "Next.js & Flutter",
+ "f2": "Docker Swarm & CI/CD",
+ "f3": "Self-Hosted Infrastruktur"
+ },
+ "description": "Student und leidenschaftlicher Self-Hoster: Ich baue Full-Stack Web-Apps und Mobile-Lösungen, betreibe meine eigene Infrastruktur und liebe DevOps.",
+ "ctaWork": "Meine Projekte",
+ "ctaContact": "Kontakt"
+ },
+ "about": {
+ "title": "Über mich",
+ "p1": "Hi, ich bin Dennis – Student und leidenschaftlicher Self-Hoster aus Osnabrück.",
+ "p2": "Ich entwickle Full-Stack Web-Apps mit Next.js und Mobile-Apps mit Flutter. Besonders spannend finde ich DevOps: eigene Infrastruktur, Automatisierung und CI/CD Deployments.",
+ "p3": "Wenn ich nicht code oder an Servern schraube, findest du mich beim Gaming, Joggen oder beim Experimentieren mit Automationen.",
+ "funFactTitle": "Fun Fact",
+ "funFactBody": "Auch wenn ich viel automatisiere, nutze ich für Kalender & Notizen noch Stift und Papier – das hilft mir beim Fokus."
+ },
+ "contact": {
+ "title": "Kontakt",
+ "subtitle": "Du willst zusammenarbeiten oder hast Fragen zu meinen Projekten? Schreib mir gerne!",
+ "getInTouch": "Melde dich",
+ "getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk."
+ }
+ }
+}
+
diff --git a/messages/en.json b/messages/en.json
new file mode 100644
index 0000000..ebe8db0
--- /dev/null
+++ b/messages/en.json
@@ -0,0 +1,52 @@
+{
+ "nav": {
+ "home": "Home",
+ "about": "About",
+ "projects": "Projects",
+ "contact": "Contact"
+ },
+ "common": {
+ "backToHome": "Back to Home",
+ "backToProjects": "Back to Projects",
+ "viewAllProjects": "View All Projects",
+ "loading": "Loading..."
+ },
+ "consent": {
+ "title": "Privacy settings",
+ "description": "We use optional services (analytics and chat) to improve the site. You can change your choice anytime.",
+ "essential": "Essential",
+ "analytics": "Analytics",
+ "chat": "Chatbot",
+ "acceptAll": "Accept all",
+ "acceptSelected": "Accept selected",
+ "rejectAll": "Reject all"
+ }
+ ,
+ "home": {
+ "hero": {
+ "features": {
+ "f1": "Next.js & Flutter",
+ "f2": "Docker Swarm & CI/CD",
+ "f3": "Self-Hosted Infrastructure"
+ },
+ "description": "Student and passionate self-hoster building full-stack web apps and mobile solutions. I run my own infrastructure and love exploring DevOps.",
+ "ctaWork": "View My Work",
+ "ctaContact": "Contact Me"
+ },
+ "about": {
+ "title": "About Me",
+ "p1": "Hi, I'm Dennis – a student and passionate self-hoster based in Osnabrück, Germany.",
+ "p2": "I love building full-stack web applications with Next.js and mobile apps with Flutter. But what really excites me is DevOps: I run my own infrastructure and automate deployments with CI/CD.",
+ "p3": "When I'm not coding or tinkering with servers, you'll find me gaming, jogging, or experimenting with automation workflows.",
+ "funFactTitle": "Fun Fact",
+ "funFactBody": "Even though I automate a lot, I still use pen and paper for my calendar and notes – it helps me stay focused."
+ },
+ "contact": {
+ "title": "Contact Me",
+ "subtitle": "Interested in working together or have questions about my projects? Feel free to reach out!",
+ "getInTouch": "Get In Touch",
+ "getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation."
+ }
+ }
+}
+
diff --git a/middleware.ts b/middleware.ts
index d197c8b..e83b6a2 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,21 +1,99 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+const SUPPORTED_LOCALES = ["en", "de"] as const;
+type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
+
+function pickLocaleFromHeader(acceptLanguage: string | null): SupportedLocale {
+ if (!acceptLanguage) return "en";
+ const lower = acceptLanguage.toLowerCase();
+ // Very small parser: prefer de, then en
+ if (lower.includes("de")) return "de";
+ if (lower.includes("en")) return "en";
+ return "en";
+}
+
+function hasLocalePrefix(pathname: string): boolean {
+ return SUPPORTED_LOCALES.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`));
+}
+
export function middleware(request: NextRequest) {
- // For /manage and /editor routes, the pages handle their own authentication
- // No middleware redirect needed - let the pages show login forms
+ const { pathname, search } = request.nextUrl;
+
+ // If a locale-prefixed request hits a public asset path (e.g. /de/images/me.jpg),
+ // redirect to the non-prefixed asset path.
+ if (hasLocalePrefix(pathname)) {
+ const rest = pathname.replace(/^\/(en|de)/, "") || "/";
+ if (rest.includes(".")) {
+ const responseUrl = request.nextUrl.clone();
+ responseUrl.pathname = rest;
+ const res = NextResponse.redirect(responseUrl);
+ return addHeaders(request, res);
+ }
+ }
+
+ // Do not locale-route public assets (anything with a dot), robots, sitemap, etc.
+ if (pathname.includes(".")) {
+ return addHeaders(request, NextResponse.next());
+ }
+
+ // Keep admin + APIs unlocalized for simplicity
+ const isAdminOrApi =
+ pathname.startsWith("/api/") ||
+ pathname === "/api" ||
+ pathname.startsWith("/manage") ||
+ pathname.startsWith("/editor");
+
+ // Locale routing for public site pages
+ const responseUrl = request.nextUrl.clone();
+
+ if (!isAdminOrApi) {
+ if (hasLocalePrefix(pathname)) {
+ // Persist locale preference
+ const locale = pathname.split("/")[1] as SupportedLocale;
+ const res = NextResponse.next();
+ res.cookies.set("NEXT_LOCALE", locale, { path: "/" });
+
+ // Continue below to add security headers
+ // eslint-disable-next-line no-use-before-define
+ return addHeaders(request, res);
+ }
+
+ // Redirect bare routes to locale-prefixed ones
+ const preferred = pickLocaleFromHeader(request.headers.get("accept-language"));
+ const redirectTarget =
+ pathname === "/" ? `/${preferred}` : `/${preferred}${pathname}${search || ""}`;
+ responseUrl.pathname = redirectTarget;
+ const res = NextResponse.redirect(responseUrl);
+ res.cookies.set("NEXT_LOCALE", preferred, { path: "/" });
+ // eslint-disable-next-line no-use-before-define
+ return addHeaders(request, res);
+ }
// Fix for 421 Misdirected Request with Nginx Proxy Manager
// Ensure proper host header handling for reverse proxy
- const hostname = request.headers.get('host') || request.headers.get('x-forwarded-host') || '';
+ const hostname = request.headers.get("host") || request.headers.get("x-forwarded-host") || "";
// Add security headers to all responses
const response = NextResponse.next();
+ return addHeaders(request, response, hostname);
+}
+
+function addHeaders(request: NextRequest, response: NextResponse, hostnameOverride?: string) {
+ const hostname =
+ hostnameOverride ??
+ request.headers.get("host") ??
+ request.headers.get("x-forwarded-host") ??
+ "";
+
// Set proper headers for Nginx Proxy Manager
if (hostname) {
- response.headers.set('X-Forwarded-Host', hostname);
- response.headers.set('X-Real-IP', request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for') || '');
+ response.headers.set("X-Forwarded-Host", hostname);
+ response.headers.set(
+ "X-Real-IP",
+ request.headers.get("x-real-ip") || request.headers.get("x-forwarded-for") || "",
+ );
}
// Security headers (complementing next.config.ts headers)
@@ -42,13 +120,11 @@ export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
- * - api/email (email API routes)
- * - api/health (health check)
+ * - api (all API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
- * - api/auth (auth API routes - need to be processed)
*/
- "/((?!api/email|api/health|_next/static|_next/image|favicon.ico|api/auth).*)",
+ "/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
diff --git a/next.config.ts b/next.config.ts
index dfcd3ee..4a20d7d 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
import dotenv from "dotenv";
import path from "path";
import bundleAnalyzer from "@next/bundle-analyzer";
+import createNextIntlPlugin from "next-intl/plugin";
// Load the .env file from the working directory
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
@@ -16,7 +17,9 @@ const nextConfig: NextConfig = {
poweredByHeader: false,
// React Strict Mode
- reactStrictMode: true,
+ // In dev, React StrictMode double-mount can cause visible animation flicker
+ // (Framer Motion "fade starts, disappears, then pops").
+ reactStrictMode: process.env.NODE_ENV === "production",
// Disable ESLint during build for Docker
eslint: {
@@ -73,6 +76,13 @@ const nextConfig: NextConfig = {
// Security and cache headers
async headers() {
+ const csp =
+ process.env.NODE_ENV === "production"
+ ? // Avoid `unsafe-eval` in production (reduces XSS impact and enables stronger CSP)
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
+ : // Dev CSP: allow eval for tooling compatibility
+ "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
+
return [
{
source: "/(.*)",
@@ -107,8 +117,7 @@ const nextConfig: NextConfig = {
},
{
key: "Content-Security-Policy",
- value:
- "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.dk0.dev; script-src-elem 'self' 'unsafe-inline' https://analytics.dk0.dev; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https:; connect-src 'self' https://analytics.dk0.dev https://api.quotable.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self';",
+ value: csp,
},
],
},
@@ -126,7 +135,11 @@ const nextConfig: NextConfig = {
headers: [
{
key: "Cache-Control",
- value: "public, max-age=31536000, immutable",
+ // In dev, aggressive caching breaks HMR and can brick a tab with stale chunks.
+ value:
+ process.env.NODE_ENV === "production"
+ ? "public, max-age=31536000, immutable"
+ : "no-store",
},
],
},
@@ -138,4 +151,6 @@ const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
-export default withBundleAnalyzer(nextConfig);
+const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
+
+export default withBundleAnalyzer(withNextIntl(nextConfig));
diff --git a/nginx.production.conf b/nginx.production.conf
index 8f67ca3..0dbd616 100644
--- a/nginx.production.conf
+++ b/nginx.production.conf
@@ -79,7 +79,8 @@ http {
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin";
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.dk0.dev; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://analytics.dk0.dev;";
+ # Avoid `unsafe-eval` in production CSP
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://analytics.dk0.dev; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://analytics.dk0.dev;";
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
diff --git a/package-lock.json b/package-lock.json
index f0392d9..8862fc7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,14 @@
"dependencies": {
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^5.22.0",
+ "@tiptap/extension-color": "^3.15.3",
+ "@tiptap/extension-highlight": "^3.15.3",
+ "@tiptap/extension-link": "^3.15.3",
+ "@tiptap/extension-text-style": "^3.15.3",
+ "@tiptap/extension-underline": "^3.15.3",
+ "@tiptap/html": "^3.15.3",
+ "@tiptap/react": "^3.15.3",
+ "@tiptap/starter-kit": "^3.15.3",
"@vercel/og": "^0.6.5",
"clsx": "^2.1.1",
"dotenv": "^16.6.1",
@@ -17,6 +25,7 @@
"gray-matter": "^4.0.3",
"lucide-react": "^0.542.0",
"next": "^15.5.7",
+ "next-intl": "^4.7.0",
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",
@@ -26,7 +35,9 @@
"react-markdown": "^10.1.0",
"react-responsive-masonry": "^2.7.1",
"redis": "^5.8.2",
- "tailwind-merge": "^2.6.0"
+ "sanitize-html": "^2.17.0",
+ "tailwind-merge": "^2.6.0",
+ "zod": "^4.3.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -42,6 +53,8 @@
"@types/react-dom": "^19",
"@types/react-responsive-masonry": "^2.6.0",
"@types/react-syntax-highlighter": "^15.5.11",
+ "@types/sanitize-html": "^2.16.0",
+ "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "^15.5.7",
@@ -49,7 +62,7 @@
"jest-environment-jsdom": "^29.7.0",
"nodemailer-mock": "^2.0.9",
"playwright": "^1.57.0",
- "postcss": "^8",
+ "postcss": "^8.4.49",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5",
@@ -1926,6 +1939,94 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+ "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
+ "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/core": "^1.7.3",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@formatjs/ecma402-abstract": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
+ "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "2.2.7",
+ "@formatjs/intl-localematcher": "0.6.2",
+ "decimal.js": "^10.4.3",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
+ "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/fast-memoize": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
+ "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/icu-messageformat-parser": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
+ "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "@formatjs/icu-skeleton-parser": "1.8.16",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/icu-skeleton-parser": {
+ "version": "1.8.16",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
+ "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@formatjs/intl-localematcher": {
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
+ "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2388,102 +2489,6 @@
"url": "https://opencollective.com/libvips"
}
},
- "node_modules/@isaacs/cliui": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "string-width": "^5.1.2",
- "string-width-cjs": "npm:string-width@^4.2.0",
- "strip-ansi": "^7.0.1",
- "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
- "wrap-ansi": "^8.1.0",
- "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -3182,15 +3187,311 @@
"node": ">=12.4.0"
}
},
- "node_modules/@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "dev": true,
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.4.tgz",
+ "integrity": "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.3",
+ "is-glob": "^4.0.3",
+ "node-addon-api": "^7.0.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.4",
+ "@parcel/watcher-darwin-arm64": "2.5.4",
+ "@parcel/watcher-darwin-x64": "2.5.4",
+ "@parcel/watcher-freebsd-x64": "2.5.4",
+ "@parcel/watcher-linux-arm-glibc": "2.5.4",
+ "@parcel/watcher-linux-arm-musl": "2.5.4",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.4",
+ "@parcel/watcher-linux-arm64-musl": "2.5.4",
+ "@parcel/watcher-linux-x64-glibc": "2.5.4",
+ "@parcel/watcher-linux-x64-musl": "2.5.4",
+ "@parcel/watcher-win32-arm64": "2.5.4",
+ "@parcel/watcher-win32-ia32": "2.5.4",
+ "@parcel/watcher-win32-x64": "2.5.4"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.4.tgz",
+ "integrity": "sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g==",
+ "cpu": [
+ "arm64"
+ ],
"license": "MIT",
"optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=14"
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.4.tgz",
+ "integrity": "sha512-kphKy377pZiWpAOyTgQYPE5/XEKVMaj6VUjKT5VkNyUJlr2qZAn8gIc7CPzx+kbhvqHDT9d7EqdOqRXT6vk0zw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.4.tgz",
+ "integrity": "sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.4.tgz",
+ "integrity": "sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.4.tgz",
+ "integrity": "sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.4.tgz",
+ "integrity": "sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.4.tgz",
+ "integrity": "sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.4.tgz",
+ "integrity": "sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.4.tgz",
+ "integrity": "sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.4.tgz",
+ "integrity": "sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.4.tgz",
+ "integrity": "sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.4.tgz",
+ "integrity": "sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.4.tgz",
+ "integrity": "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@playwright/test": {
@@ -3343,6 +3644,12 @@
"@redis/client": "^5.10.0"
}
},
+ "node_modules/@remirror/core-constants": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
+ "license": "MIT"
+ },
"node_modules/@resvg/resvg-wasm": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz",
@@ -3366,6 +3673,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@schummar/icu-type-parser": {
+ "version": "1.21.5",
+ "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
+ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
+ "license": "MIT"
+ },
"node_modules/@shuding/opentype.js": {
"version": "1.4.0-beta.0",
"resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz",
@@ -4044,6 +4357,172 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.8.tgz",
+ "integrity": "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-darwin-x64": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.8.tgz",
+ "integrity": "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm-gnueabihf": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.8.tgz",
+ "integrity": "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-gnu": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.8.tgz",
+ "integrity": "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-musl": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.8.tgz",
+ "integrity": "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-gnu": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.8.tgz",
+ "integrity": "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-musl": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.8.tgz",
+ "integrity": "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-arm64-msvc": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.8.tgz",
+ "integrity": "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-ia32-msvc": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.8.tgz",
+ "integrity": "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-x64-msvc": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.8.tgz",
+ "integrity": "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -4053,6 +4532,15 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@swc/types": {
+ "version": "0.1.25",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
+ "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -4128,6 +4616,494 @@
}
}
},
+ "node_modules/@tiptap/core": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
+ "integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.15.3.tgz",
+ "integrity": "sha512-13x5UsQXtttFpoS/n1q173OeurNxppsdWgP3JfsshzyxIghhC141uL3H6SGYQLPU31AizgDs2OEzt6cSUevaZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-bold": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.15.3.tgz",
+ "integrity": "sha512-I8JYbkkUTNUXbHd/wCse2bR0QhQtJD7+0/lgrKOmGfv5ioLxcki079Nzuqqay3PjgYoJLIJQvm3RAGxT+4X91w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.15.3.tgz",
+ "integrity": "sha512-e88DG1bTy6hKxrt7iPVQhJnH5/EOrnKpIyp09dfRDgWrrW88fE0Qjys7a/eT8W+sXyXM3z10Ye7zpERWsrLZDg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.15.3.tgz",
+ "integrity": "sha512-MGwEkNT7ltst6XaWf0ObNgpKQ4PvuuV3igkBrdYnQS+qaAx9IF4isygVPqUc9DvjYC306jpyKsNqNrENIXcosA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.15.3.tgz",
+ "integrity": "sha512-x6LFt3Og6MFINYpsMzrJnz7vaT9Yk1t4oXkbJsJRSavdIWBEBcoRudKZ4sSe/AnsYlRJs8FY2uR76mt9e+7xAQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.15.3.tgz",
+ "integrity": "sha512-q1UB9icNfdJppTqMIUWfoRKkx5SSdWIpwZoL2NeOI5Ah3E20/dQKVttIgLhsE521chyvxCYCRaHD5tMNGKfhyw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-color": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.15.3.tgz",
+ "integrity": "sha512-GS+LEJ7YC7J6CiQ/caTDVyKg+ZlU4B5ofzAZ0iCWPahjMyUUZImzXvoRlfMumAiPG+IUW9PC2BztSGd3SCLpGA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-text-style": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-document": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.15.3.tgz",
+ "integrity": "sha512-AC72nI2gnogBuETCKbZekn+h6t5FGGcZG2abPGKbz/x9rwpb6qV2hcbAQ30t6M7H6cTOh2/Ut8bEV2MtMB15sw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.15.3.tgz",
+ "integrity": "sha512-jGI5XZpdo8GSYQFj7HY15/oEwC2m2TqZz0/Fln5qIhY32XlZhWrsMuMI6WbUJrTH16es7xO6jmRlDsc6g+vJWg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.15.3.tgz",
+ "integrity": "sha512-+3DVBleKKffadEJEdLYxmYAJOjHjLSqtiSFUE3RABT4V2ka1ODy2NIpyKX0o1SvQ5N1jViYT9Q+yUbNa6zCcDw==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.15.3.tgz",
+ "integrity": "sha512-Kaw0sNzP0bQI/xEAMSfIpja6xhsu9WqqAK/puzOIS1RKWO47Wps/tzqdSJ9gfslPIb5uY5mKCfy8UR8Xgiia8w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.15.3.tgz",
+ "integrity": "sha512-8HjxmeRbBiXW+7JKemAJtZtHlmXQ9iji398CPQ0yYde68WbIvUhHXjmbJE5pxFvvQTJ/zJv1aISeEOZN2bKBaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.15.3.tgz",
+ "integrity": "sha512-G1GG6iN1YXPS+75arDpo+bYRzhr3dNDw99c7D7na3aDawa9Qp7sZ/bVrzFUUcVEce0cD6h83yY7AooBxEc67hA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-highlight": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.15.3.tgz",
+ "integrity": "sha512-ZZyuKGW4WrMx3pBEfsHqOcqEklfiiAjVuvhji9FJcip1w0B2OnMWkgZw7rdAlsQG8pGH6NWh9Gf2DOUsjuAa6A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.15.3.tgz",
+ "integrity": "sha512-FYkN7L6JsfwwNEntmLklCVKvgL0B0N47OXMacRk6kYKQmVQ4Nvc7q/VJLpD9sk4wh4KT1aiCBfhKEBTu5pv1fg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.15.3.tgz",
+ "integrity": "sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.15.3.tgz",
+ "integrity": "sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.15.3.tgz",
+ "integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.15.3.tgz",
+ "integrity": "sha512-CCxL5ek1p0lO5e8aqhnPzIySldXRSigBFk2fP9OLgdl5qKFLs2MGc19jFlx5+/kjXnEsdQTFbGY1Sizzt0TVDw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.15.3.tgz",
+ "integrity": "sha512-UxqnTEEAKrL+wFQeSyC9z0mgyUUVRS2WTcVFoLZCE6/Xus9F53S4bl7VKFadjmqI4GpDk5Oe2IOUc72o129jWg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.15.3.tgz",
+ "integrity": "sha512-/8uhw528Iy0c9wF6tHCiIn0ToM0Ml6Ll2c/3iPRnKr4IjXwx2Lr994stUFihb+oqGZwV1J8CPcZJ4Ufpdqi4Dw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.15.3.tgz",
+ "integrity": "sha512-lc0Qu/1AgzcEfS67NJMj5tSHHhH6NtA6uUpvppEKGsvJwgE2wKG1onE4isrVXmcGRdxSMiCtyTDemPNMu6/ozQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.15.3.tgz",
+ "integrity": "sha512-Y1P3eGNY7RxQs2BcR6NfLo9VfEOplXXHAqkOM88oowWWOE7dMNeFFZM9H8HNxoQgXJ7H0aWW9B7ZTWM9hWli2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.15.3.tgz",
+ "integrity": "sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-text-style": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.15.3.tgz",
+ "integrity": "sha512-/M7fuGRPVkeM14rQ1bNiLZUs2N+FuVhIsLEwNKKk7GaTGKHzmkC1b2COmbICivuFYf90KWzaG0R+Pm7cnW6KaA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.15.3.tgz",
+ "integrity": "sha512-r/IwcNN0W366jGu4Y0n2MiFq9jGa4aopOwtfWO4d+J0DyeS2m7Go3+KwoUqi0wQTiVU74yfi4DF6eRsMQ9/iHQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.15.3.tgz",
+ "integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ }
+ },
+ "node_modules/@tiptap/html": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-3.15.3.tgz",
+ "integrity": "sha512-ftoWrgev05gDyor3YtJ5LJ0KHb/CKTR45zltGB9/cn+3IAOGuDrhmd8qO3o+E2VbsKR50yaiOCxtS36HYM9tQA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3",
+ "happy-dom": "^20.0.2"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
+ "integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-collab": "^1.3.1",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-markdown": "^1.13.1",
+ "prosemirror-menu": "^1.2.4",
+ "prosemirror-model": "^1.24.1",
+ "prosemirror-schema-basic": "^1.2.3",
+ "prosemirror-schema-list": "^1.5.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.6.4",
+ "prosemirror-trailing-node": "^3.0.0",
+ "prosemirror-transform": "^1.10.2",
+ "prosemirror-view": "^1.38.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/react": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.15.3.tgz",
+ "integrity": "sha512-XvouB+Hrqw8yFmZLPEh+HWlMeRSjZfHSfWfWuw5d8LSwnxnPeu3Bg/rjHrRrdwb+7FumtzOnNWMorpb/PSOttQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "fast-equals": "^5.3.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "optionalDependencies": {
+ "@tiptap/extension-bubble-menu": "^3.15.3",
+ "@tiptap/extension-floating-menu": "^3.15.3"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/pm": "^3.15.3",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "3.15.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.15.3.tgz",
+ "integrity": "sha512-ia+eQr9Mt1ln2UO+kK4kFTJOrZK4GhvZXFjpCCYuHtco3rhr2fZAIxEEY4cl/vo5VO5WWyPqxhkFeLcoWmNjSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "^3.15.3",
+ "@tiptap/extension-blockquote": "^3.15.3",
+ "@tiptap/extension-bold": "^3.15.3",
+ "@tiptap/extension-bullet-list": "^3.15.3",
+ "@tiptap/extension-code": "^3.15.3",
+ "@tiptap/extension-code-block": "^3.15.3",
+ "@tiptap/extension-document": "^3.15.3",
+ "@tiptap/extension-dropcursor": "^3.15.3",
+ "@tiptap/extension-gapcursor": "^3.15.3",
+ "@tiptap/extension-hard-break": "^3.15.3",
+ "@tiptap/extension-heading": "^3.15.3",
+ "@tiptap/extension-horizontal-rule": "^3.15.3",
+ "@tiptap/extension-italic": "^3.15.3",
+ "@tiptap/extension-link": "^3.15.3",
+ "@tiptap/extension-list": "^3.15.3",
+ "@tiptap/extension-list-item": "^3.15.3",
+ "@tiptap/extension-list-keymap": "^3.15.3",
+ "@tiptap/extension-ordered-list": "^3.15.3",
+ "@tiptap/extension-paragraph": "^3.15.3",
+ "@tiptap/extension-strike": "^3.15.3",
+ "@tiptap/extension-text": "^3.15.3",
+ "@tiptap/extension-underline": "^3.15.3",
+ "@tiptap/extensions": "^3.15.3",
+ "@tiptap/pm": "^3.15.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -4357,6 +5333,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -4365,6 +5357,12 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "license": "MIT"
+ },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -4374,7 +5372,6 @@
"version": "20.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
- "dev": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4413,7 +5410,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -4438,6 +5434,16 @@
"@types/react": "*"
}
},
+ "node_modules/@types/sanitize-html": {
+ "version": "2.16.0",
+ "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz",
+ "integrity": "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "htmlparser2": "^8.0.0"
+ }
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -4457,6 +5463,29 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "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",
+ "peer": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -4906,7 +5935,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
@@ -5100,6 +6128,44 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/autoprefixer": {
+ "version": "10.4.20",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
+ "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.23.3",
+ "caniuse-lite": "^1.0.30001646",
+ "fraction.js": "^4.3.7",
+ "normalize-range": "^0.1.2",
+ "picocolors": "^1.0.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -5287,6 +6353,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.14",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
+ "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -5331,9 +6407,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.24.4",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
- "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -5351,10 +6427,11 @@
],
"license": "MIT",
"dependencies": {
- "caniuse-lite": "^1.0.30001688",
- "electron-to-chromium": "^1.5.73",
- "node-releases": "^2.0.19",
- "update-browserslist-db": "^1.1.1"
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -5483,9 +6560,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001700",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
- "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
+ "version": "1.0.30001764",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
+ "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
"funding": [
{
"type": "opencollective",
@@ -5817,6 +6894,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@@ -6048,7 +7131,6 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
- "dev": true,
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
@@ -6089,7 +7171,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6154,7 +7235,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -6235,6 +7315,32 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
"node_modules/domexception": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@@ -6249,6 +7355,35 @@
"node": ">=12"
}
},
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -6282,17 +7417,10 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"license": "MIT"
},
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/electron-to-chromium": {
- "version": "1.5.103",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
- "integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
+ "version": "1.5.267",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
+ "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -6334,7 +7462,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -7146,6 +8273,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -7315,36 +8451,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "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==",
- "dev": true,
- "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/foreground-child/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==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
@@ -7361,6 +8467,20 @@
"node": ">= 6"
}
},
+ "node_modules/fraction.js": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+ "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "patreon",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
"node_modules/framer-motion": {
"version": "12.25.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.25.0.tgz",
@@ -7399,6 +8519,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -7729,6 +8850,23 @@
"uglify-js": "^3.1.4"
}
},
+ "node_modules/happy-dom": {
+ "version": "20.1.0",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.1.0.tgz",
+ "integrity": "sha512-ebvqjBqzenBk2LjzNEAzoj7yhw7rW/R2/wVevMu6Mrq3MXtcI/RUz4+ozpcOcqVLEWPqLfg2v9EAU7fFXZUUJw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/node": "^20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.18.3"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -7901,6 +9039,25 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
@@ -8059,6 +9216,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/intl-messageformat": {
+ "version": "10.7.18",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
+ "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "2.3.6",
+ "@formatjs/fast-memoize": "2.2.7",
+ "@formatjs/icu-messageformat-parser": "2.11.4",
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -8268,7 +9437,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -8333,7 +9501,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -8679,22 +9846,6 @@
"node": ">= 0.4"
}
},
- "node_modules/jackspeak": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
- "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "@isaacs/cliui": "^8.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- },
- "optionalDependencies": {
- "@pkgjs/parseargs": "^0.11.0"
- }
- },
"node_modules/jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@@ -9870,6 +11021,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/linkifyjs": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
+ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
+ "license": "MIT"
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -9983,6 +11149,23 @@
"tmpl": "1.0.5"
}
},
+ "node_modules/markdown-it": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
+ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -10139,6 +11322,12 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "license": "MIT"
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -10657,16 +11846,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
"node_modules/motion-dom": {
"version": "12.24.11",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz",
@@ -10734,6 +11913,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@@ -10793,6 +11981,92 @@
}
}
},
+ "node_modules/next-intl": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.7.0.tgz",
+ "integrity": "sha512-gvROzcNr/HM0jTzQlKWQxUNk8jrZ0bREz+bht3wNbv+uzlZ5Kn3J+m+viosub18QJ72S08UJnVK50PXWcUvwpQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/amannn"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/intl-localematcher": "^0.5.4",
+ "@parcel/watcher": "^2.4.1",
+ "@swc/core": "^1.15.2",
+ "negotiator": "^1.0.0",
+ "next-intl-swc-plugin-extractor": "^4.7.0",
+ "po-parser": "^2.1.1",
+ "use-intl": "^4.7.0"
+ },
+ "peerDependencies": {
+ "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
+ "typescript": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next-intl-swc-plugin-extractor": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.7.0.tgz",
+ "integrity": "sha512-iAqflu2FWdQMWhwB0B2z52X7LmEpvnMNJXqVERZQ7bK5p9iqQLu70ur6Ka6NfiXLxfb+AeAkUX5qIciQOg+87A==",
+ "license": "MIT"
+ },
+ "node_modules/next-intl/node_modules/@swc/core": {
+ "version": "1.15.8",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz",
+ "integrity": "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.25"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.15.8",
+ "@swc/core-darwin-x64": "1.15.8",
+ "@swc/core-linux-arm-gnueabihf": "1.15.8",
+ "@swc/core-linux-arm64-gnu": "1.15.8",
+ "@swc/core-linux-arm64-musl": "1.15.8",
+ "@swc/core-linux-x64-gnu": "1.15.8",
+ "@swc/core-linux-x64-musl": "1.15.8",
+ "@swc/core-win32-arm64-msvc": "1.15.8",
+ "@swc/core-win32-ia32-msvc": "1.15.8",
+ "@swc/core-win32-x64-msvc": "1.15.8"
+ },
+ "peerDependencies": {
+ "@swc/helpers": ">=0.5.17"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next-intl/node_modules/@swc/helpers": {
+ "version": "0.5.18",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
+ "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -10821,6 +12095,12 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "license": "MIT"
+ },
"node_modules/node-cache": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz",
@@ -10883,9 +12163,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.19",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
- "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -10921,6 +12201,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -11126,6 +12416,12 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -11186,13 +12482,6 @@
"node": ">=6"
}
},
- "node_modules/package-json-from-dist": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
- "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
- "dev": true,
- "license": "BlueOak-1.0.0"
- },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
@@ -11264,6 +12553,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
+ "license": "MIT"
+ },
"node_modules/parse5": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
@@ -11314,30 +12609,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/path-scurry": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
- "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "lru-cache": "^10.2.0",
- "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
- },
- "engines": {
- "node": ">=16 || 14 >=14.18"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/path-scurry/node_modules/lru-cache": {
- "version": "10.4.3",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
- "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11482,6 +12753,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -11492,6 +12764,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/po-parser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
+ "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==",
+ "license": "MIT"
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -11503,10 +12781,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
- "dev": true,
+ "version": "8.4.49",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
+ "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [
{
"type": "opencollective",
@@ -11523,7 +12800,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.7",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -11550,10 +12827,20 @@
}
},
"node_modules/postcss-js": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
- "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
@@ -11561,10 +12848,6 @@
"engines": {
"node": "^12 || ^14 || >= 16"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
"peerDependencies": {
"postcss": "^8.4.21"
}
@@ -11751,6 +13034,201 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/prosemirror-changeset": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
+ "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-collab": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
+ "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
+ "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+ "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-markdown": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
+ "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/markdown-it": "^14.0.0",
+ "markdown-it": "^14.0.0",
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-menu": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
+ "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "crelt": "^1.0.0",
+ "prosemirror-commands": "^1.0.0",
+ "prosemirror-history": "^1.0.0",
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
+ "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-basic": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
+ "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-trailing-node": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
+ "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remirror/core-constants": "3.0.0",
+ "escape-string-regexp": "^4.0.0"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.22.1",
+ "prosemirror-state": "^1.4.2",
+ "prosemirror-view": "^1.33.8"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.10.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
+ "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.41.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
+ "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.20.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -11774,6 +13252,15 @@
"node": ">=6"
}
},
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -12119,6 +13606,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -12205,6 +13698,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/sanitize-html": {
+ "version": "2.17.0",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
+ "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^4.2.2",
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^8.0.0",
+ "is-plain-object": "^5.0.0",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.3.11"
+ }
+ },
"node_modules/satori": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/satori/-/satori-0.12.2.tgz",
@@ -12625,29 +14132,6 @@
"node": ">=8"
}
},
- "node_modules/string-width-cjs": {
- "name": "string-width",
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width-cjs/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -12800,20 +14284,6 @@
"node": ">=8"
}
},
- "node_modules/strip-ansi-cjs": {
- "name": "strip-ansi",
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-bom": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
@@ -12922,18 +14392,18 @@
}
},
"node_modules/sucrase": {
- "version": "3.35.0",
- "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
- "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
- "glob": "^10.3.10",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
@@ -12944,52 +14414,6 @@
"node": ">=16 || 14 >=14.17"
}
},
- "node_modules/sucrase/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
- "dev": true,
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/sucrase/node_modules/glob": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
- "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/sucrase/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -13595,7 +15019,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -13605,6 +15029,12 @@
"node": ">=14.17"
}
},
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -13641,8 +15071,7 @@
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"node_modules/unicode-trie": {
"version": "2.0.0",
@@ -13746,9 +15175,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
- "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -13797,6 +15226,29 @@
"requires-port": "^1.0.0"
}
},
+ "node_modules/use-intl": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.7.0.tgz",
+ "integrity": "sha512-jyd8nSErVRRsSlUa+SDobKHo9IiWs5fjcPl9VBUnzUyEQpVM5mwJCgw8eUiylhvBpLQzUGox1KN0XlRivSID9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "^2.2.0",
+ "@schummar/icu-type-parser": "1.21.5",
+ "intl-messageformat": "^10.5.14"
+ },
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -13852,6 +15304,12 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
@@ -13966,7 +15424,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -14125,25 +15582,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
- "node_modules/wrap-ansi-cjs": {
- "name": "wrap-ansi",
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -14166,10 +15604,9 @@
}
},
"node_modules/ws": {
- "version": "8.18.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
- "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
- "dev": true,
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -14222,16 +15659,19 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
- "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
- "node": ">= 14"
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
@@ -14292,6 +15732,15 @@
"integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==",
"license": "MIT"
},
+ "node_modules/zod": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
+ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/package.json b/package.json
index 07f62ed..84ab681 100644
--- a/package.json
+++ b/package.json
@@ -10,14 +10,14 @@
"db:seed": "tsx -r dotenv/config prisma/seed.ts dotenv_config_path=.env.local",
"build": "next build",
"start": "next start",
- "lint": "eslint .",
+ "lint": "cross-env NODE_ENV=development eslint .",
"lint:fix": "eslint . --fix",
"pre-push": "./scripts/pre-push.sh",
"pre-push:full": "./scripts/pre-push-full.sh",
"pre-push:quick": "./scripts/pre-push-quick.sh",
"test:all": "npm run test && npm run test:e2e",
"buildAnalyze": "cross-env ANALYZE=true next build",
- "test": "jest",
+ "test": "cross-env NODE_ENV=test jest",
"test:production": "NODE_ENV=production jest --config jest.config.production.ts",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
@@ -54,6 +54,14 @@
"dependencies": {
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^5.22.0",
+ "@tiptap/extension-color": "^3.15.3",
+ "@tiptap/extension-highlight": "^3.15.3",
+ "@tiptap/extension-link": "^3.15.3",
+ "@tiptap/extension-text-style": "^3.15.3",
+ "@tiptap/extension-underline": "^3.15.3",
+ "@tiptap/html": "^3.15.3",
+ "@tiptap/react": "^3.15.3",
+ "@tiptap/starter-kit": "^3.15.3",
"@vercel/og": "^0.6.5",
"clsx": "^2.1.1",
"dotenv": "^16.6.1",
@@ -61,6 +69,7 @@
"gray-matter": "^4.0.3",
"lucide-react": "^0.542.0",
"next": "^15.5.7",
+ "next-intl": "^4.7.0",
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",
@@ -70,7 +79,9 @@
"react-markdown": "^10.1.0",
"react-responsive-masonry": "^2.7.1",
"redis": "^5.8.2",
- "tailwind-merge": "^2.6.0"
+ "sanitize-html": "^2.17.0",
+ "tailwind-merge": "^2.6.0",
+ "zod": "^4.3.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -86,6 +97,8 @@
"@types/react-dom": "^19",
"@types/react-responsive-masonry": "^2.6.0",
"@types/react-syntax-highlighter": "^15.5.11",
+ "@types/sanitize-html": "^2.16.0",
+ "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "^15.5.7",
@@ -93,7 +106,7 @@
"jest-environment-jsdom": "^29.7.0",
"nodemailer-mock": "^2.0.9",
"playwright": "^1.57.0",
- "postcss": "^8",
+ "postcss": "^8.4.49",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5",
diff --git a/playwright.config.ts b/playwright.config.ts
index 0f8a941..fd47133 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -44,7 +44,9 @@ export default defineConfig({
],
webServer: {
- command: 'npm run dev',
+ // Use plain Next.js dev server for E2E (no Docker dependency)
+ // Force NODE_ENV=development to avoid Edge runtime eval issues in middleware bundle
+ command: 'NODE_ENV=development npm run dev:next',
url: 'http://localhost:3000',
reuseExistingServer: true, // Always reuse if server is running
timeout: 120 * 1000,
diff --git a/prisma/migrations/20260112150721_init/migration.sql b/prisma/migrations/20260112150721_init/migration.sql
new file mode 100644
index 0000000..1733e62
--- /dev/null
+++ b/prisma/migrations/20260112150721_init/migration.sql
@@ -0,0 +1,246 @@
+-- CreateEnum
+CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED');
+
+-- CreateEnum
+CREATE TYPE "Difficulty" AS ENUM ('BEGINNER', 'INTERMEDIATE', 'ADVANCED', 'EXPERT');
+
+-- CreateEnum
+CREATE TYPE "InteractionType" AS ENUM ('LIKE', 'SHARE', 'BOOKMARK', 'COMMENT');
+
+-- CreateTable
+CREATE TABLE "Project" (
+ "id" SERIAL NOT NULL,
+ "slug" VARCHAR(255) NOT NULL,
+ "defaultLocale" VARCHAR(10) NOT NULL DEFAULT 'en',
+ "title" VARCHAR(255) NOT NULL,
+ "description" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "featured" BOOLEAN NOT NULL DEFAULT false,
+ "category" VARCHAR(100) NOT NULL,
+ "date" VARCHAR(10) NOT NULL,
+ "github" VARCHAR(500),
+ "live" VARCHAR(500),
+ "published" BOOLEAN NOT NULL DEFAULT true,
+ "imageUrl" VARCHAR(500),
+ "metaDescription" TEXT,
+ "keywords" TEXT,
+ "ogImage" VARCHAR(500),
+ "schema" JSONB,
+ "difficulty" "Difficulty" NOT NULL DEFAULT 'INTERMEDIATE',
+ "timeToComplete" VARCHAR(100),
+ "technologies" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "challenges" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "lessonsLearned" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "futureImprovements" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "demoVideo" VARCHAR(500),
+ "screenshots" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "colorScheme" VARCHAR(100) NOT NULL DEFAULT 'Dark',
+ "accessibility" BOOLEAN NOT NULL DEFAULT true,
+ "performance" JSONB NOT NULL DEFAULT '{"loadTime": "1.5s", "bundleSize": "50KB", "lighthouse": 90}',
+ "analytics" JSONB NOT NULL DEFAULT '{"likes": 0, "views": 0, "shares": 0}',
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "project_translations" (
+ "id" SERIAL NOT NULL,
+ "project_id" INTEGER NOT NULL,
+ "locale" VARCHAR(10) NOT NULL,
+ "title" VARCHAR(255) NOT NULL,
+ "description" TEXT NOT NULL,
+ "content" JSONB,
+ "metaDescription" TEXT,
+ "keywords" TEXT,
+ "ogImage" VARCHAR(500),
+ "schema" JSONB,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "project_translations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "content_pages" (
+ "id" SERIAL NOT NULL,
+ "key" VARCHAR(100) NOT NULL,
+ "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED',
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "content_pages_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "content_page_translations" (
+ "id" SERIAL NOT NULL,
+ "page_id" INTEGER NOT NULL,
+ "locale" VARCHAR(10) NOT NULL,
+ "title" TEXT,
+ "slug" VARCHAR(255),
+ "content" JSONB NOT NULL,
+ "metaDescription" TEXT,
+ "keywords" TEXT,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "content_page_translations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "site_settings" (
+ "id" INTEGER NOT NULL DEFAULT 1,
+ "defaultLocale" VARCHAR(10) NOT NULL DEFAULT 'en',
+ "locales" TEXT[] DEFAULT ARRAY['en', 'de']::TEXT[],
+ "theme" JSONB,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "site_settings_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PageView" (
+ "id" SERIAL NOT NULL,
+ "project_id" INTEGER,
+ "page" VARCHAR(100) NOT NULL,
+ "ip" VARCHAR(45),
+ "user_agent" TEXT,
+ "referrer" VARCHAR(500),
+ "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PageView_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "UserInteraction" (
+ "id" SERIAL NOT NULL,
+ "project_id" INTEGER NOT NULL,
+ "type" "InteractionType" NOT NULL,
+ "ip" VARCHAR(45),
+ "user_agent" TEXT,
+ "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "UserInteraction_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Contact" (
+ "id" SERIAL NOT NULL,
+ "name" VARCHAR(255) NOT NULL,
+ "email" VARCHAR(255) NOT NULL,
+ "subject" VARCHAR(500) NOT NULL,
+ "message" TEXT NOT NULL,
+ "responded" BOOLEAN NOT NULL DEFAULT false,
+ "response_template" VARCHAR(50),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Contact_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "activity_status" (
+ "id" INTEGER NOT NULL DEFAULT 1,
+ "activity_type" VARCHAR(50),
+ "activity_details" VARCHAR(255),
+ "activity_project" VARCHAR(255),
+ "activity_language" VARCHAR(50),
+ "activity_repo" VARCHAR(500),
+ "music_playing" BOOLEAN NOT NULL DEFAULT false,
+ "music_track" VARCHAR(255),
+ "music_artist" VARCHAR(255),
+ "music_album" VARCHAR(255),
+ "music_platform" VARCHAR(50),
+ "music_progress" INTEGER,
+ "music_album_art" VARCHAR(500),
+ "watching_title" VARCHAR(255),
+ "watching_platform" VARCHAR(50),
+ "watching_type" VARCHAR(50),
+ "gaming_game" VARCHAR(255),
+ "gaming_platform" VARCHAR(50),
+ "gaming_status" VARCHAR(50),
+ "status_mood" VARCHAR(50),
+ "status_message" VARCHAR(500),
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "activity_status_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
+
+-- CreateIndex
+CREATE INDEX "Project_category_idx" ON "Project"("category");
+
+-- CreateIndex
+CREATE INDEX "Project_featured_idx" ON "Project"("featured");
+
+-- CreateIndex
+CREATE INDEX "Project_published_idx" ON "Project"("published");
+
+-- CreateIndex
+CREATE INDEX "Project_difficulty_idx" ON "Project"("difficulty");
+
+-- CreateIndex
+CREATE INDEX "Project_created_at_idx" ON "Project"("created_at");
+
+-- CreateIndex
+CREATE INDEX "Project_tags_idx" ON "Project"("tags");
+
+-- CreateIndex
+CREATE INDEX "project_translations_locale_idx" ON "project_translations"("locale");
+
+-- CreateIndex
+CREATE INDEX "project_translations_project_id_idx" ON "project_translations"("project_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "project_translations_project_id_locale_key" ON "project_translations"("project_id", "locale");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "content_pages_key_key" ON "content_pages"("key");
+
+-- CreateIndex
+CREATE INDEX "content_page_translations_locale_idx" ON "content_page_translations"("locale");
+
+-- CreateIndex
+CREATE INDEX "content_page_translations_slug_idx" ON "content_page_translations"("slug");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "content_page_translations_page_id_locale_key" ON "content_page_translations"("page_id", "locale");
+
+-- CreateIndex
+CREATE INDEX "PageView_project_id_idx" ON "PageView"("project_id");
+
+-- CreateIndex
+CREATE INDEX "PageView_timestamp_idx" ON "PageView"("timestamp");
+
+-- CreateIndex
+CREATE INDEX "PageView_page_idx" ON "PageView"("page");
+
+-- CreateIndex
+CREATE INDEX "UserInteraction_project_id_idx" ON "UserInteraction"("project_id");
+
+-- CreateIndex
+CREATE INDEX "UserInteraction_type_idx" ON "UserInteraction"("type");
+
+-- CreateIndex
+CREATE INDEX "UserInteraction_timestamp_idx" ON "UserInteraction"("timestamp");
+
+-- CreateIndex
+CREATE INDEX "Contact_email_idx" ON "Contact"("email");
+
+-- CreateIndex
+CREATE INDEX "Contact_responded_idx" ON "Contact"("responded");
+
+-- CreateIndex
+CREATE INDEX "Contact_created_at_idx" ON "Contact"("created_at");
+
+-- AddForeignKey
+ALTER TABLE "project_translations" ADD CONSTRAINT "project_translations_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "content_page_translations" ADD CONSTRAINT "content_page_translations_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "content_pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
diff --git a/prisma/migrations/README.md b/prisma/migrations/README.md
deleted file mode 100644
index b43642a..0000000
--- a/prisma/migrations/README.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# Database Migrations
-
-This directory contains SQL migration scripts for manual database updates.
-
-## Running Migrations
-
-### Method 1: Using psql (Recommended)
-
-```bash
-# Connect to your database
-psql -d portfolio -f prisma/migrations/create_activity_status.sql
-
-# Or with connection string
-psql "postgresql://user:password@localhost:5432/portfolio" -f prisma/migrations/create_activity_status.sql
-```
-
-### Method 2: Using Docker
-
-```bash
-# If your database is in Docker
-docker exec -i postgres_container psql -U username -d portfolio < prisma/migrations/create_activity_status.sql
-```
-
-### Method 3: Using pgAdmin or Database GUI
-
-1. Open pgAdmin or your database GUI
-2. Connect to your `portfolio` database
-3. Open Query Tool
-4. Copy and paste the contents of `create_activity_status.sql`
-5. Execute the query
-
-## Verifying Migration
-
-After running the migration, verify it was successful:
-
-```bash
-# Check if table exists
-psql -d portfolio -c "\dt activity_status"
-
-# View table structure
-psql -d portfolio -c "\d activity_status"
-
-# Check if default row was inserted
-psql -d portfolio -c "SELECT * FROM activity_status;"
-```
-
-Expected output:
-```
- id | activity_type | ... | updated_at
-----+---------------+-----+---------------------------
- 1 | | ... | 2024-01-15 10:30:00+00
-```
-
-## Migration: create_activity_status.sql
-
-**Purpose**: Creates the `activity_status` table for n8n activity feed integration.
-
-**What it does**:
-- Creates `activity_status` table with all necessary columns
-- Inserts a default row with `id = 1`
-- Sets up automatic `updated_at` timestamp trigger
-- Adds table comment for documentation
-
-**Required by**:
-- `/api/n8n/status` endpoint
-- `ActivityFeed` component
-- n8n workflows for status updates
-
-**Safe to run multiple times**: Yes (uses `IF NOT EXISTS` and `ON CONFLICT`)
-
-## Troubleshooting
-
-### "relation already exists"
-Table already exists - migration is already applied. Safe to ignore.
-
-### "permission denied"
-Your database user needs CREATE TABLE permissions:
-```sql
-GRANT CREATE ON DATABASE portfolio TO your_user;
-```
-
-### "database does not exist"
-Create the database first:
-```bash
-createdb portfolio
-# Or
-psql -c "CREATE DATABASE portfolio;"
-```
-
-### "connection refused"
-Ensure PostgreSQL is running:
-```bash
-# Check status
-pg_isready
-
-# Start PostgreSQL (macOS)
-brew services start postgresql
-
-# Start PostgreSQL (Linux)
-sudo systemctl start postgresql
-```
-
-## Rolling Back
-
-To remove the activity_status table:
-
-```sql
-DROP TRIGGER IF EXISTS activity_status_updated_at ON activity_status;
-DROP FUNCTION IF EXISTS update_activity_status_updated_at();
-DROP TABLE IF EXISTS activity_status;
-```
-
-Save this as `rollback_activity_status.sql` and run if needed.
-
-## Future Migrations
-
-When adding new migrations:
-1. Create a new `.sql` file with descriptive name
-2. Use timestamps in filename: `YYYYMMDD_description.sql`
-3. Document what it does in this README
-4. Test on local database first
-5. Mark as safe/unsafe for production
-
----
-
-**Last Updated**: 2024-01-15
-**Status**: Required for n8n integration
\ No newline at end of file
diff --git a/prisma/migrations/create_activity_status.sql b/prisma/migrations/create_activity_status.sql
deleted file mode 100644
index c435677..0000000
--- a/prisma/migrations/create_activity_status.sql
+++ /dev/null
@@ -1,49 +0,0 @@
--- Create activity_status table for n8n integration
-CREATE TABLE IF NOT EXISTS activity_status (
- id INTEGER PRIMARY KEY DEFAULT 1,
- activity_type VARCHAR(50),
- activity_details VARCHAR(255),
- activity_project VARCHAR(255),
- activity_language VARCHAR(50),
- activity_repo VARCHAR(500),
- music_playing BOOLEAN DEFAULT FALSE,
- music_track VARCHAR(255),
- music_artist VARCHAR(255),
- music_album VARCHAR(255),
- music_platform VARCHAR(50),
- music_progress INTEGER,
- music_album_art VARCHAR(500),
- watching_title VARCHAR(255),
- watching_platform VARCHAR(50),
- watching_type VARCHAR(50),
- gaming_game VARCHAR(255),
- gaming_platform VARCHAR(50),
- gaming_status VARCHAR(50),
- status_mood VARCHAR(50),
- status_message VARCHAR(500),
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
-);
-
--- Insert default row
-INSERT INTO activity_status (id, updated_at)
-VALUES (1, NOW())
-ON CONFLICT (id) DO NOTHING;
-
--- Create function to automatically update updated_at
-CREATE OR REPLACE FUNCTION update_activity_status_updated_at()
-RETURNS TRIGGER AS $$
-BEGIN
- NEW.updated_at = NOW();
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
--- Create trigger for automatic timestamp updates
-DROP TRIGGER IF EXISTS activity_status_updated_at ON activity_status;
-CREATE TRIGGER activity_status_updated_at
- BEFORE UPDATE ON activity_status
- FOR EACH ROW
- EXECUTE FUNCTION update_activity_status_updated_at();
-
--- Add helpful comment
-COMMENT ON TABLE activity_status IS 'Stores real-time activity status from n8n workflows (coding, music, gaming, etc.)';
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..2fe25d8
--- /dev/null
+++ b/prisma/migrations/migration_lock.toml
@@ -0,0 +1 @@
+provider = "postgresql"
diff --git a/prisma/migrations/quick-fix.sh b/prisma/migrations/quick-fix.sh
deleted file mode 100755
index 70d5f09..0000000
--- a/prisma/migrations/quick-fix.sh
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/bin/bash
-
-# Quick Fix Script for Portfolio Database
-# This script creates the activity_status table needed for n8n integration
-
-set -e
-
-echo "🔧 Portfolio Database Quick Fix"
-echo "================================"
-echo ""
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-NC='\033[0m' # No Color
-
-# Check if .env.local exists
-if [ ! -f .env.local ]; then
- echo -e "${RED}❌ Error: .env.local not found${NC}"
- echo "Please create .env.local with DATABASE_URL"
- exit 1
-fi
-
-# Load DATABASE_URL from .env.local
-export $(grep -v '^#' .env.local | xargs)
-
-if [ -z "$DATABASE_URL" ]; then
- echo -e "${RED}❌ Error: DATABASE_URL not found in .env.local${NC}"
- exit 1
-fi
-
-echo -e "${GREEN}✓ Found DATABASE_URL${NC}"
-echo ""
-
-# Extract database name from DATABASE_URL
-DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p')
-echo "📦 Database: $DB_NAME"
-echo ""
-
-# Run the migration
-echo "🚀 Creating activity_status table..."
-echo ""
-
-psql "$DATABASE_URL" -f prisma/migrations/create_activity_status.sql
-
-if [ $? -eq 0 ]; then
- echo ""
- echo -e "${GREEN}✅ SUCCESS! Migration completed${NC}"
- echo ""
- echo "Verifying table..."
- psql "$DATABASE_URL" -c "\d activity_status" | head -20
- echo ""
- echo "Checking default row..."
- psql "$DATABASE_URL" -c "SELECT id, updated_at FROM activity_status LIMIT 1;"
- echo ""
- echo -e "${GREEN}🎉 All done! Your database is ready.${NC}"
- echo ""
- echo "Next steps:"
- echo " 1. Restart your Next.js dev server: npm run dev"
- echo " 2. Visit http://localhost:3000"
- echo " 3. The activity feed should now work without errors"
-else
- echo ""
- echo -e "${RED}❌ Migration failed${NC}"
- echo ""
- echo "Troubleshooting:"
- echo " 1. Ensure PostgreSQL is running: pg_isready"
- echo " 2. Check your DATABASE_URL in .env.local"
- echo " 3. Verify database exists: psql -l | grep $DB_NAME"
- echo " 4. Try manual migration: psql $DB_NAME -f prisma/migrations/create_activity_status.sql"
- exit 1
-fi
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2705de9..81fee79 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -9,6 +9,8 @@ datasource db {
model Project {
id Int @id @default(autoincrement())
+ slug String @unique @db.VarChar(255)
+ defaultLocale String @default("en") @db.VarChar(10)
title String @db.VarChar(255)
description String
content String
@@ -39,6 +41,8 @@ model Project {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+ translations ProjectTranslation[]
+
@@index([category])
@@index([featured])
@@index([published])
@@ -47,6 +51,75 @@ model Project {
@@index([tags])
}
+model ProjectTranslation {
+ id Int @id @default(autoincrement())
+ projectId Int @map("project_id")
+ locale String @db.VarChar(10)
+ title String @db.VarChar(255)
+ description String
+ content Json?
+ metaDescription String?
+ keywords String?
+ ogImage String? @db.VarChar(500)
+ schema Json?
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+
+ @@unique([projectId, locale])
+ @@index([locale])
+ @@index([projectId])
+ @@map("project_translations")
+}
+
+model ContentPage {
+ id Int @id @default(autoincrement())
+ key String @unique @db.VarChar(100)
+ status ContentStatus @default(PUBLISHED)
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ translations ContentPageTranslation[]
+
+ @@map("content_pages")
+}
+
+model ContentPageTranslation {
+ id Int @id @default(autoincrement())
+ pageId Int @map("page_id")
+ locale String @db.VarChar(10)
+ title String?
+ slug String? @db.VarChar(255)
+ content Json
+ metaDescription String?
+ keywords String?
+ updatedAt DateTime @updatedAt @map("updated_at")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ page ContentPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
+
+ @@unique([pageId, locale])
+ @@index([locale])
+ @@index([slug])
+ @@map("content_page_translations")
+}
+
+model SiteSettings {
+ id Int @id @default(1)
+ defaultLocale String @default("en") @db.VarChar(10)
+ locales String[] @default(["en","de"])
+ theme Json?
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@map("site_settings")
+}
+
+enum ContentStatus {
+ DRAFT
+ PUBLISHED
+}
+
model PageView {
id Int @id @default(autoincrement())
projectId Int? @map("project_id")
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 100932d..7087fee 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -1,7 +1,20 @@
import { PrismaClient } from "@prisma/client";
+import { slugify } from "../lib/slug";
const prisma = new PrismaClient();
+function tiptapParagraph(text: string) {
+ return {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text }],
+ },
+ ],
+ };
+}
+
async function main() {
console.log("🌱 Seeding database...");
@@ -10,6 +23,104 @@ async function main() {
await prisma.pageView.deleteMany();
await prisma.project.deleteMany();
+ // Ensure base site settings & minimal localized CMS defaults (do NOT overwrite existing content).
+ await prisma.siteSettings.upsert({
+ where: { id: 1 },
+ update: {},
+ create: { id: 1, defaultLocale: "en", locales: ["en", "de"] },
+ });
+
+ async function ensureContentPage(
+ key: string,
+ translations: Array<{ locale: "en" | "de"; title: string; contentText: string }>,
+ ) {
+ const page = await prisma.contentPage.upsert({
+ where: { key },
+ update: {},
+ create: { key, status: "PUBLISHED" },
+ });
+
+ for (const tr of translations) {
+ await prisma.contentPageTranslation.upsert({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ where: { pageId_locale: { pageId: page.id, locale: tr.locale } } as any,
+ update: {},
+ create: {
+ pageId: page.id,
+ locale: tr.locale,
+ title: tr.title,
+ content: tiptapParagraph(tr.contentText),
+ },
+ });
+ }
+ }
+
+ await ensureContentPage("home-hero", [
+ {
+ locale: "en",
+ title: "Hero",
+ contentText: "I build fast, secure, self-hosted platforms — and I love clean UX.",
+ },
+ {
+ locale: "de",
+ title: "Hero",
+ contentText: "Ich baue schnelle, sichere, selbst gehostete Plattformen — mit sauberem UX.",
+ },
+ ]);
+
+ await ensureContentPage("home-about", [
+ {
+ locale: "en",
+ title: "About",
+ contentText: "I’m a software engineer focused on performance, security, and maintainable systems.",
+ },
+ {
+ locale: "de",
+ title: "Über mich",
+ contentText: "Ich bin Software Engineer mit Fokus auf Performance, Security und wartbare Systeme.",
+ },
+ ]);
+
+ await ensureContentPage("home-contact", [
+ {
+ locale: "en",
+ title: "Contact",
+ contentText: "Want to work together? Send me a message and I’ll get back to you.",
+ },
+ {
+ locale: "de",
+ title: "Kontakt",
+ contentText: "Lust auf Zusammenarbeit? Schreib mir und ich melde mich zurück.",
+ },
+ ]);
+
+ // These are used by /[locale]/legal-notice and /[locale]/privacy-policy (re-exported pages)
+ await ensureContentPage("legal-notice", [
+ {
+ locale: "en",
+ title: "Legal notice",
+ contentText: "Legal notice content can be edited in the CMS per language.",
+ },
+ {
+ locale: "de",
+ title: "Impressum",
+ contentText: "Impressum-Inhalt kann im CMS pro Sprache bearbeitet werden.",
+ },
+ ]);
+
+ await ensureContentPage("privacy-policy", [
+ {
+ locale: "en",
+ title: "Privacy policy",
+ contentText: "Privacy policy content can be edited in the CMS per language.",
+ },
+ {
+ locale: "de",
+ title: "Datenschutzerklärung",
+ contentText: "Datenschutzerklärung kann im CMS pro Sprache bearbeitet werden.",
+ },
+ ]);
+
// Create real projects
const projects = [
{
@@ -947,10 +1058,21 @@ Visit any non-existent page on the site to see the terminal in action. Or click
},
];
+ const usedSlugs = new Set();
for (const project of projects) {
+ const baseSlug = slugify(project.title);
+ let slug = baseSlug;
+ let counter = 2;
+ while (usedSlugs.has(slug) || !slug) {
+ slug = `${baseSlug || "project"}-${counter++}`;
+ }
+ usedSlugs.add(slug);
+
await prisma.project.create({
data: {
...project,
+ slug,
+ defaultLocale: "en",
difficulty: project.difficulty as
| "BEGINNER"
| "INTERMEDIATE"
diff --git a/public/robots.txt b/public/robots.txt
deleted file mode 100644
index 69da470..0000000
--- a/public/robots.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-User-agent: *
-Allow: /
-Disallow: /legal-notice
-Disallow: /privacy-policy
-Sitemap: https://dki.one/sitemap.xml
diff --git a/push-to-dev.sh b/push-to-dev.sh
index ffdb42f..f98dd2b 100755
--- a/push-to-dev.sh
+++ b/push-to-dev.sh
@@ -80,7 +80,7 @@ echo -e "${YELLOW}[4/5] Verifying critical files...${NC}"
REQUIRED_FILES=(
"CHANGELOG_DEV.md"
"AFTER_PUSH_SETUP.md"
- "prisma/migrations/create_activity_status.sql"
+ "prisma/migrations/migration_lock.toml"
"docs/ai-image-generation/README.md"
)
MISSING=0
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 4dc45cf..f5ce37d 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -8,7 +8,7 @@ set -e
# Configuration
ENVIRONMENT=${1:-production}
REGISTRY="ghcr.io"
-IMAGE_NAME="dennis-konkol/my_portfolio"
+IMAGE_NAME="dennis-konkol/portfolio"
CONTAINER_NAME="portfolio-app"
COMPOSE_FILE="docker-compose.production.yml"
diff --git a/scripts/dev-minimal.js b/scripts/dev-minimal.js
index ef78e2e..a597203 100644
--- a/scripts/dev-minimal.js
+++ b/scripts/dev-minimal.js
@@ -1,7 +1,7 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-require-imports */
-const { spawn, exec } = require('child_process');
+const { spawn, exec } = require("child_process");
const os = require('os');
const isWindows = process.platform === 'win32';
@@ -15,9 +15,23 @@ console.log(`🖥️ Detected architecture: ${arch}`);
console.log(`🍎 Apple Silicon: ${isAppleSilicon ? 'Yes' : 'No'}`);
// Use minimal compose file (only PostgreSQL and Redis)
-const composeFile = 'docker-compose.dev.minimal.yml';
+const composeFile = "docker-compose.dev.minimal.yml";
console.log(`📦 Using minimal Docker Compose file: ${composeFile}`);
+function runCommand(command, env) {
+ return new Promise((resolve, reject) => {
+ exec(command, { env: env ?? process.env }, (error, stdout, stderr) => {
+ if (stdout) process.stdout.write(stdout);
+ if (stderr) process.stderr.write(stderr);
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve(undefined);
+ });
+ });
+}
+
// Check if docker-compose is available
exec('docker-compose --version', (error) => {
if (error) {
@@ -43,40 +57,60 @@ exec('docker-compose --version', (error) => {
// Wait a bit for services to be ready
setTimeout(() => {
- console.log('🚀 Starting Next.js development server...');
-
- // Start Next.js dev server
- const nextProcess = spawn('npm', ['run', 'dev:next'], {
- stdio: 'inherit',
- shell: isWindows,
- env: {
- ...process.env,
- DATABASE_URL: process.env.DATABASE_URL || 'postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public',
- REDIS_URL: 'redis://localhost:6379',
- NODE_ENV: 'development'
+ const devEnv = {
+ ...process.env,
+ DATABASE_URL:
+ process.env.DATABASE_URL ||
+ "postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public",
+ REDIS_URL: "redis://localhost:6379",
+ NODE_ENV: "development",
+ };
+
+ // Ensure DB schema exists before starting Next dev server.
+ // This prevents "table does not exist" errors for Projects/CMS/Analytics.
+ (async () => {
+ try {
+ console.log("🗄️ Applying Prisma migrations (dev)...");
+ await runCommand("npx prisma generate", devEnv);
+ await runCommand("npx prisma migrate deploy", devEnv);
+ // seeding is optional; keep it non-blocking
+ await runCommand("npx prisma db seed", devEnv).catch(() => {});
+ console.log("✅ Database is ready");
+ } catch (_e) {
+ console.warn("⚠️ Could not run Prisma migrations automatically.");
+ console.warn(" You can run: npm run db:setup");
}
- });
- nextProcess.on('close', (code) => {
- console.log(`Next.js dev server exited with code ${code}`);
- });
+ console.log("🚀 Starting Next.js development server...");
+
+ // Start Next.js dev server
+ const nextProcess = spawn("npm", ["run", "dev:next"], {
+ stdio: "inherit",
+ shell: isWindows,
+ env: devEnv,
+ });
- // Handle process signals
- process.on('SIGINT', () => {
- console.log('\n🛑 Stopping development environment...');
- nextProcess.kill('SIGTERM');
-
- // Stop Docker services
- const stopProcess = spawn('docker-compose', ['-f', composeFile, 'down'], {
- stdio: 'inherit',
- shell: isWindows
+ nextProcess.on("close", (code) => {
+ console.log(`Next.js dev server exited with code ${code}`);
});
-
- stopProcess.on('close', () => {
- console.log('✅ Development environment stopped');
- process.exit(0);
+
+ // Handle process signals
+ process.on("SIGINT", () => {
+ console.log("\n🛑 Stopping development environment...");
+ nextProcess.kill("SIGTERM");
+
+ // Stop Docker services
+ const stopProcess = spawn("docker-compose", ["-f", composeFile, "down"], {
+ stdio: "inherit",
+ shell: isWindows,
+ });
+
+ stopProcess.on("close", () => {
+ console.log("✅ Development environment stopped");
+ process.exit(0);
+ });
});
- });
+ })();
}, 5000); // Wait 5 seconds for services to be ready
diff --git a/scripts/setup-database.js b/scripts/setup-database.js
index 3588f7f..fae6da7 100644
--- a/scripts/setup-database.js
+++ b/scripts/setup-database.js
@@ -32,8 +32,8 @@ async function setupDatabase() {
console.log('📦 Generating Prisma client...');
await runCommand('npx prisma generate');
- console.log('🔄 Pushing database schema...');
- await runCommand('npx prisma db push');
+ console.log('🔄 Applying database migrations...');
+ await runCommand('npx prisma migrate deploy');
console.log('🌱 Seeding database...');
await runCommand('npx prisma db seed');
diff --git a/scripts/start-with-migrate.js b/scripts/start-with-migrate.js
new file mode 100644
index 0000000..bc3c2bf
--- /dev/null
+++ b/scripts/start-with-migrate.js
@@ -0,0 +1,68 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+/**
+ * Container entrypoint: apply Prisma migrations, then start Next server.
+ *
+ * Why:
+ * - In real deployments you want schema changes applied automatically per deploy.
+ * - `prisma migrate deploy` is safe to run multiple times (idempotent).
+ *
+ * Controls:
+ * - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging).
+ */
+const { spawnSync } = require("node:child_process");
+const fs = require("node:fs");
+const path = require("node:path");
+
+function run(cmd, args, opts = {}) {
+ const res = spawnSync(cmd, args, {
+ stdio: "inherit",
+ env: process.env,
+ ...opts,
+ });
+
+ if (res.error) {
+ throw res.error;
+ }
+ if (typeof res.status === "number" && res.status !== 0) {
+ // propagate exit code
+ process.exit(res.status);
+ }
+}
+
+const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
+if (!skip) {
+ const autoBaseline =
+ String(process.env.PRISMA_AUTO_BASELINE || "").toLowerCase() === "true";
+
+ // Avoid relying on `npx` resolution in minimal runtimes.
+ // We copy `node_modules/prisma` into the runtime image.
+ if (autoBaseline) {
+ try {
+ const migrationsDir = path.join(process.cwd(), "prisma", "migrations");
+ const entries = fs
+ .readdirSync(migrationsDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name);
+ const initMigration = entries.find((n) => n.endsWith("_init"));
+ if (initMigration) {
+ // This is the documented "baseline" flow for existing databases:
+ // mark the initial migration as already applied.
+ run("node", [
+ "node_modules/prisma/build/index.js",
+ "migrate",
+ "resolve",
+ "--applied",
+ initMigration,
+ ]);
+ }
+ } catch (_err) {
+ // If baseline fails we continue to migrate deploy, which will surface the real issue.
+ }
+ }
+ run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
+} else {
+ console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
+}
+
+run("node", ["server.js"]);
+
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
deleted file mode 100644
index 344ea9e..0000000
--- a/test-results/.last-run.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "status": "interrupted",
- "failedTests": []
-}
\ No newline at end of file