From 69392c850a33f01d8a85dea27402364466401444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20He=C3=9Fe?= Date: Fri, 14 Aug 2026 22:43:40 +0200 Subject: [PATCH] feat: harden persistence and enrich portfolio projects --- .env.example | 7 + README.md | 22 +- index.html | 6 +- server.ts | 292 ++++++++++++++++++++++---- src/App.tsx | 37 ++-- src/components/AdminPanel.tsx | 29 ++- src/components/ClaraPortfolio.tsx | 3 + src/components/EroticaPortfolio.tsx | 3 + src/components/ProjectDetailMedia.tsx | 55 +++++ src/components/ReneePortfolio.tsx | 3 + src/components/SciFiPortfolio.tsx | 3 + src/types.ts | 11 + 12 files changed, 403 insertions(+), 68 deletions(-) create mode 100644 src/components/ProjectDetailMedia.tsx diff --git a/.env.example b/.env.example index 7a550fe..f93640d 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,13 @@ # Users configure this via the Secrets panel in the AI Studio UI. GEMINI_API_KEY="MY_GEMINI_API_KEY" +# Required in production. There is a development fallback, but it must not be +# used for an internet-facing deployment. +ADMIN_PASSWORD="CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD" + +# Optional location for database.json, uploads and migration backups. +# DATA_DIR="./data" + # APP_URL: The URL where this applet is hosted. # AI Studio automatically injects this at runtime with the Cloud Run service URL. # Used for self-referential links, OAuth callbacks, and API endpoints. diff --git a/README.md b/README.md index b0186f4..864ec1e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 📚 Dual Identity Autoren-Zentrale (Selbstgehostet) -Willkommen in Ihrer modernen, reaktiven Autoren-Website für das 21. Jahrhundert! Dieses System ermöglicht es Ihnen, zwei völlig eigenständige Autorenprofile mit individuellen Themen, Biografien, Schreibprojekten und Bücherregalen über eine einzige, passwortgeschützte Administrationsoberfläche zu pflegen. +Willkommen in Ihrer modernen, reaktiven Autoren-Website für das 21. Jahrhundert! Dieses System ermöglicht es Ihnen, mehrere eigenständige Autorenprofile mit individuellen Themen, Biografien, Schreibprojekten und Bücherregalen über eine einzige, passwortgeschützte Administrationsoberfläche zu pflegen. --- @@ -10,16 +10,16 @@ Bisher liefen klassische Portfolios oft auf einem standardmäßigen **LAMP-Stack 1. **Vollständige Kapselung (Kein Server-Wildwuchs):** Bei einem LAMP-Stack müssen Sie Apache/Nginx, PHP-Laufzeiten und eine MySQL-Datenbank auf dem Host installieren und managen. Unser Docker-Stack bündelt das gesamte Frontend, das Express-Backend und die Speicherrelevanz in einem **einzigen, autarken Container**. 2. **Blitzschnelle, reaktive Benutzeroberfläche (SPA):** Das Frontend nutzt modernste Webtechnologie (React & Tailwind CSS). Seitenwechsel und Buchdetails laden augenblicklich im Browser des Nutzers, ohne das klassische, träge Neuladen von PHP-Seiten. -3. **Einfachheit bei Datensicherungen (Backups):** Anstatt komplexe MySQL-Dumps durchzuführen, speichert unsere Anwendung alle Buchtitel, Projekte, Cover und Biografien in einer schlanken, robusten JSON-Datenbankdatei (`/data/database.json`). Ein Backup lässt sich durch einfaches Kopieren dieser Datei anfertigen! +3. **Einfachheit bei Datensicherungen (Backups):** Anstatt komplexe MySQL-Dumps durchzuführen, speichert unsere Anwendung alle Buchtitel, Projekte, Cover und Biografien in einer schlanken JSON-Datenbankdatei (`/data/database.json`). Ein Backup lässt sich durch einfaches Kopieren dieser Datei anfertigen! 4. **KI-Unterstützung integriert:** Der integrierte **Gemini-Klappentext-Assistent** läuft nahtlos und sicher über das Node-Backend (ohne API-Schlüssel im Webbrowser zu exponieren). --- ## 🌌 Die Trennung der Profile -Das System ist von Grund auf so strukturiert, dass Besucher der Website **keine Verbindung** zwischen Ihren beiden Identitäten herstellen können: +Das System ist von Grund auf so strukturiert, dass Besucher der Website **keine Verbindung** zwischen den gepflegten Identitäten herstellen müssen. Der Code enthält derzeit vier getrennt auflösbare Profile; Domains und Pfade können im Adminbereich gepflegt werden. Unter anderem: - **Echte Identität (Daniel Hesse — Sci-Fi & Fantasy):** Erreichbar auf der Standard-Startseite (`/`). Das Design kombiniert dunkelblaue Weltraum-Energie, Monospace-Einflüsse und kosmische Magie-Akzente. -- **Pseudonym (M. S. Velvet — Sinnliche Literatur):** Erreichbar **ausschließlich** über den Pfad `/sensual-moments`. Sie verwendet ein warmes, edles "Champagner-Gold & Samt-Bordeaux"-Thema mit klassischer und anspruchsvoller Serif-Asthetik. Es gibt keinerlei gegenseitige Verlinkungen! +- **Pseudonym (Annie Slone — Sinnliche Literatur):** Erreichbar unter anderem über den Pfad `/sensual-moments`. Das Profil verwendet ein warmes, edles Thema mit klassischer und anspruchsvoller Serif-Asthetik. Es gibt keinerlei gegenseitige Verlinkungen! --- @@ -84,9 +84,21 @@ Sollten Sie die Software direkt auf Ihrem Server (ohne Docker) starten wollen: ## 🧱 Datensicherung / Backups -Alle Ihre wertvollen Inhalte (Biografien, Projektfortschritte und alle Bücherregal-Einträge) liegen verschlüsselt strukturiert in Ihrem Projektordner unter: +Alle Ihre Inhalte (Biografien, Projektfortschritte und Bücherregal-Einträge) liegen als lesbares JSON in Ihrem Projektordner unter: `./data/database.json` Da wir in der `docker-compose.yml` ein **lokales Volume** gemountet haben (`./data:/app/data`), wird diese Datei direkt auf die Festplatte Ihres Servers geschrieben. - **Backup erstellen:** Sichern Sie einfach die Datei `./data/database.json`. - **Wiederherstellen:** Platzieren Sie eine gesicherte `database.json` in den `./data/`-Ordner vor dem Starten des Containers. + +Beim Ergänzen neuer Datenfelder legt die Anwendung vor der Migration automatisch eine unveränderte Sicherung unter `./data/backups/` an. Schreibvorgänge erfolgen atomar und werden nacheinander verarbeitet, damit die Datei bei parallelen Änderungen nicht teilweise überschrieben wird. Das Docker-Volume ersetzt dennoch kein externes Backup des Hostsystems. + +--- + +## Betrieb & SEO + +- `/health/live` zeigt an, ob der Prozess läuft. +- `/health/ready` zeigt an, ob die Daten erfolgreich geladen wurden und die Anwendung bereit ist. +- Seitentitel, Beschreibung, Canonical URL, Open-Graph-Daten und strukturierte Personendaten werden passend zur aufgerufenen Autorendomain serverseitig ausgegeben. +- `/robots.txt` und `/sitemap.xml` werden ebenfalls profilabhängig erzeugt; der Adminbereich ist mit `noindex` gekennzeichnet. +- Optionale SEO-Felder können später je Profil gepflegt werden. Ohne sie werden die Angaben rückwärtskompatibel aus den vorhandenen Profildaten abgeleitet. diff --git a/index.html b/index.html index 21dfe69..7cfd1be 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,13 @@ - + - My Google AI Studio App + + Autoren-Portfolio
- diff --git a/server.ts b/server.ts index 89f963d..8110f32 100644 --- a/server.ts +++ b/server.ts @@ -1,7 +1,6 @@ import express from "express"; import path from "path"; import fs from "fs/promises"; -import { fileURLToPath } from "url"; import dotenv from "dotenv"; import { GoogleGenAI } from "@google/genai"; import { defaultAuthorData } from "./src/defaultData.js"; @@ -9,33 +8,84 @@ import { AuthorData, AuthorProfile } from "./src/types.js"; dotenv.config(); -const currentFilename = typeof import.meta !== "undefined" && import.meta.url - ? fileURLToPath(import.meta.url) - : (typeof __filename !== "undefined" ? __filename : ""); - -const currentDirname = typeof import.meta !== "undefined" && import.meta.url - ? path.dirname(currentFilename) - : (typeof __dirname !== "undefined" ? __dirname : ""); - const app = express(); const PORT = 3000; // Path to durable local database file -const DATA_DIR = path.join(process.cwd(), "data"); +const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data")); const DATA_FILE = path.join(DATA_DIR, "database.json"); +const BACKUP_DIR = path.join(DATA_DIR, "backups"); +const CURRENT_SCHEMA_VERSION = 1; +let writeQueue: Promise = Promise.resolve(); +let serverReady = false; + +const profileKeys = ["scifi", "erotica", "clara", "renee"] as const; +type ProfileKey = typeof profileKeys[number]; + +function isAuthorData(value: unknown): value is AuthorData { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return profileKeys.every((key) => { + const profile = candidate[key] as Partial | undefined; + return !!profile && typeof profile.name === "string" && + typeof profile.bio === "string" && Array.isArray(profile.books) && + Array.isArray(profile.projects); + }); +} + +async function writeFileAtomically(data: AuthorData): Promise { + const temporaryFile = `${DATA_FILE}.${process.pid}.${Date.now()}.tmp`; + try { + await fs.writeFile(temporaryFile, JSON.stringify(data, null, 2), "utf-8"); + await fs.rename(temporaryFile, DATA_FILE); + } finally { + await fs.unlink(temporaryFile).catch(() => undefined); + } +} + +function updateDatabase(update: (current: AuthorData) => AuthorData): Promise { + let savedData: AuthorData; + const operation = writeQueue.then(async () => { + const nextData = update(dbCache); + await writeFileAtomically(nextData); + dbCache = nextData; + savedData = nextData; + }); + writeQueue = operation.catch(() => undefined); + return operation.then(() => savedData!); +} + +async function createMigrationBackup(rawData: string): Promise { + await fs.mkdir(BACKUP_DIR, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + await fs.writeFile(path.join(BACKUP_DIR, `database-before-migration-${timestamp}.json`), rawData, "utf-8"); +} // Establish initial database of authors async function initDatabase(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true }); try { - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true }); - try { const existingData = await fs.readFile(DATA_FILE, "utf-8"); - const parsed = JSON.parse(existingData) as AuthorData; + if (!existingData.trim()) { + const initialData: AuthorData = { + ...defaultAuthorData, + schemaVersion: CURRENT_SCHEMA_VERSION, + revision: 0, + }; + await createMigrationBackup(existingData); + await writeFileAtomically(initialData); + return initialData; + } + const rawParsed = JSON.parse(existingData) as unknown; + if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) { + throw new Error("database.json besitzt nicht die erwartete Grundstruktur."); + } + // Legacy files may not contain all newer profiles yet; migrations below add them. + const parsed = rawParsed as AuthorData; let modified = false; // Ensure all dynamic static text and design properties exist for all profiles - const profileKeys: ("scifi" | "erotica" | "clara" | "renee")[] = ["scifi", "erotica", "clara", "renee"]; for (const pKey of profileKeys) { if (!parsed[pKey]) { parsed[pKey] = defaultAuthorData[pKey]; @@ -79,29 +129,39 @@ async function initDatabase(): Promise { modified = true; } + if (parsed.schemaVersion === undefined) { + parsed.schemaVersion = CURRENT_SCHEMA_VERSION; + modified = true; + } + if (parsed.revision === undefined) { + parsed.revision = 0; + modified = true; + } + + if (!isAuthorData(parsed)) { + throw new Error("database.json ist auch nach der Migration nicht vollständig gültig."); + } + if (modified) { - await fs.writeFile(DATA_FILE, JSON.stringify(parsed, null, 2), "utf-8"); + await createMigrationBackup(existingData); + await writeFileAtomically(parsed); } return parsed; - } catch { - // If file doesn't exist, seed it with default data - await fs.writeFile(DATA_FILE, JSON.stringify(defaultAuthorData, null, 2), "utf-8"); - return defaultAuthorData; - } - } catch (err) { - console.error("Database storage initialization failed, using default data in-memory:", err); - return defaultAuthorData; + } catch (error: any) { + if (error?.code !== "ENOENT") throw error; + const initialData: AuthorData = { + ...defaultAuthorData, + schemaVersion: CURRENT_SCHEMA_VERSION, + revision: 0, + }; + await writeFileAtomically(initialData); + return initialData; } } // In-memory runtime data cache, synced to active storage file let dbCache: AuthorData; -// Initialize on boot -initDatabase().then((data) => { - dbCache = data; -}); - // Configure middleware app.use(express.json({ limit: "10mb" })); @@ -142,11 +202,111 @@ const verifyToken = (req: express.Request, res: express.Response, next: express. } }; +function cleanDomain(value: string): string { + return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]; +} + +function profileForRequest(hostname: string, pathname: string): ProfileKey { + const normalizedHost = cleanDomain(hostname); + const standardPaths: Record = { + erotica: ["/sensual-moments", "/annie-slone", "/marc-velvet"], + clara: ["/clara-finch", "/clara"], + renee: ["/renee-heart", "/renee"], + scifi: ["/sci-fi", "/daniel-hesse"], + }; + const standardDomains: Record = { + erotica: ["annieslone.de", "marcvelvet.de"], + clara: ["clarafinch.de", "clara-finch.de"], + renee: ["reneeheart.de", "renee-heart.de"], + scifi: ["hesse-sf.de", "danielhesse.de"], + }; + + for (const key of profileKeys) { + const configuredPaths = (dbCache[key].customPath || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean); + for (const value of [...configuredPaths, ...standardPaths[key]]) { + const prefix = value.startsWith("/") ? value : `/${value}`; + if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return key; + } + } + for (const key of profileKeys) { + const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean); + for (const domain of [...configuredDomains, ...standardDomains[key]]) { + if (normalizedHost === domain || normalizedHost.endsWith(`.${domain}`)) return key; + } + } + return "scifi"; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => ({ + "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", + })[character]!); +} + +function absoluteUrl(value: string | undefined, origin: string): string | undefined { + if (!value) return undefined; + try { + return new URL(value, origin).toString(); + } catch { + return undefined; + } +} + +function seoMeta(req: express.Request): string { + const key = profileForRequest(req.hostname, req.path); + const profile = dbCache[key]; + const forwardedProtocol = req.header("x-forwarded-proto")?.split(",")[0].trim(); + const protocol = forwardedProtocol === "https" ? "https" : req.protocol; + const origin = `${protocol}://${req.get("host")}`; + const configuredDomain = cleanDomain(profile.customDomain?.split(",")[0] || ""); + const canonical = configuredDomain ? `https://${configuredDomain}` : origin; + const title = profile.seoTitle?.trim() || `${profile.name} – ${profile.heroSubtitle || "Autor"}`; + const description = (profile.seoDescription?.trim() || profile.bio).replace(/\s+/g, " ").slice(0, 160); + const image = absoluteUrl(profile.socialImageUrl || profile.heroBannerUrl || profile.avatarUrl, origin); + const robots = profile.noIndex || req.path.startsWith("/admin") ? "noindex, nofollow, noarchive" : "index, follow"; + const structuredData = JSON.stringify({ + "@context": "https://schema.org", + "@type": "Person", + name: profile.name, + url: canonical, + image, + jobTitle: "Autor", + knowsAbout: profile.books.flatMap((book) => book.genres || []), + }).replace(/${escapeHtml(title)}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + image ? `` : "", + ``, + ``, + ].filter(Boolean).join("\n "); +} + // --- API ROUTES --- // 1. Get entire public author setup app.get("/api/author-data", (req, res) => { - res.json(dbCache || defaultAuthorData); + res.json(dbCache); +}); + +app.get("/health/live", (_req, res) => { + res.json({ status: "ok" }); +}); + +app.get("/health/ready", (_req, res) => { + if (!serverReady || !dbCache) { + res.status(503).json({ status: "not_ready" }); + return; + } + res.json({ status: "ok", revision: dbCache.revision ?? 0 }); }); // 2. Manage Admin Login @@ -174,9 +334,16 @@ app.post("/api/admin/save-profile", verifyToken, async (req, res) => { } try { - dbCache[profileKey] = profileData; - await fs.writeFile(DATA_FILE, JSON.stringify(dbCache, null, 2), "utf-8"); - res.json({ success: true, message: "Profil erfolgreich gespeichert." }); + if (!profileData || typeof profileData !== "object" || !Array.isArray(profileData.books) || !Array.isArray(profileData.projects)) { + res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." }); + return; + } + const nextData = await updateDatabase((current) => ({ + ...current, + [profileKey]: profileData, + revision: (current.revision ?? 0) + 1, + })); + res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." }); } catch (err: any) { console.error("Failed to write to database.json:", err); res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." }); @@ -192,9 +359,12 @@ app.post("/api/admin/save-legal", verifyToken, async (req, res) => { } try { - dbCache.legalDocuments = legalDocuments; - await fs.writeFile(DATA_FILE, JSON.stringify(dbCache, null, 2), "utf-8"); - res.json({ success: true, message: "Rechtliche Dokumente erfolgreich gespeichert." }); + const nextData = await updateDatabase((current) => ({ + ...current, + legalDocuments, + revision: (current.revision ?? 0) + 1, + })); + res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." }); } catch (err: any) { console.error("Failed to write legal documents to database.json:", err); res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." }); @@ -285,7 +455,7 @@ app.get("/api/admin/list-uploads", verifyToken, async (req, res) => { // Configure Vite middleware or static serve async function startServer() { // Serve the dynamic uploads directory statically - app.use("/uploads", express.static(path.join(process.cwd(), "data", "uploads"))); + app.use("/uploads", express.static(path.join(DATA_DIR, "uploads"))); if (process.env.NODE_ENV !== "production") { const { createServer: createViteServer } = await import("vite"); @@ -296,15 +466,53 @@ async function startServer() { app.use(vite.middlewares); } else { const distPath = path.join(process.cwd(), "dist"); - app.use(express.static(distPath)); - app._router.get("*", (req: express.Request, res: express.Response) => { - res.sendFile(path.join(distPath, "index.html")); + const indexTemplate = await fs.readFile(path.join(distPath, "index.html"), "utf-8"); + + app.get("/robots.txt", (req, res) => { + const key = profileForRequest(req.hostname, req.path); + const domain = cleanDomain(dbCache[key].customDomain?.split(",")[0] || req.get("host") || ""); + res.type("text/plain").send(`User-agent: *\nAllow: /\nDisallow: /admin\n\nSitemap: https://${domain}/sitemap.xml\n`); + }); + + app.get("/sitemap.xml", (req, res) => { + const key = profileForRequest(req.hostname, req.path); + const profile = dbCache[key]; + const domain = cleanDomain(profile.customDomain?.split(",")[0] || req.get("host") || ""); + const location = `https://${domain}/`; + res.type("application/xml").send(`\n${escapeHtml(location)}`); + }); + + app.use(express.static(distPath, { index: false })); + app.get("*", (req: express.Request, res: express.Response) => { + const html = indexTemplate.replace("\n Autoren-Portfolio", seoMeta(req)); + if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive"); + res.type("html").send(html); }); } - app.listen(PORT, "0.0.0.0", () => { + const server = app.listen(PORT, "0.0.0.0", () => { + serverReady = true; console.log(`Server running on http://0.0.0.0:${PORT}`); }); + + const shutdown = (signal: string) => { + serverReady = false; + console.log(`${signal} received, shutting down gracefully.`); + server.close(() => { + writeQueue.finally(() => process.exit(0)); + }); + setTimeout(() => process.exit(1), 10_000).unref(); + }; + process.once("SIGTERM", () => shutdown("SIGTERM")); + process.once("SIGINT", () => shutdown("SIGINT")); } -startServer(); +async function main() { + dbCache = await initDatabase(); + await startServer(); +} + +main().catch((error) => { + console.error("Server startup failed; existing data was not overwritten:", error); + process.exit(1); +}); diff --git a/src/App.tsx b/src/App.tsx index d97e8c6..b5e292c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,21 @@ -import { useState, useEffect } from "react"; +import { lazy, Suspense, useState, useEffect } from "react"; import { AuthorData } from "./types"; -import SciFiPortfolio from "./components/SciFiPortfolio"; -import EroticaPortfolio from "./components/EroticaPortfolio"; -import ClaraPortfolio from "./components/ClaraPortfolio"; -import ReneePortfolio from "./components/ReneePortfolio"; -import AdminPanel from "./components/AdminPanel"; -import { Lock, Eye, Sparkles, RefreshCw, Leaf } from "lucide-react"; +import { Lock, RefreshCw } from "lucide-react"; + +const SciFiPortfolio = lazy(() => import("./components/SciFiPortfolio")); +const EroticaPortfolio = lazy(() => import("./components/EroticaPortfolio")); +const ClaraPortfolio = lazy(() => import("./components/ClaraPortfolio")); +const ReneePortfolio = lazy(() => import("./components/ReneePortfolio")); +const AdminPanel = lazy(() => import("./components/AdminPanel")); + +function LoadingScreen() { + return ( +
+ +

Autoren-Portfolio wird geladen...

+
+ ); +} export default function App() { const [authorData, setAuthorData] = useState(null); @@ -123,12 +133,7 @@ export default function App() { }; if (loading) { - return ( -
- -

Kryptografisches Terminal bootet...

-
- ); + return ; } if (!authorData) { @@ -147,7 +152,7 @@ export default function App() { // Purely route-based render isolation structure // 1. Administration panel if (currentPath === "/admin" || currentPath.startsWith("/admin/")) { - return ; + return }>; } // Render active portfolio @@ -167,7 +172,7 @@ export default function App() { }; return ( - <> + }> {renderActivePortfolio()} {/* Absolute floating administration bypass link hidden on the bottom left (hover to show) */} @@ -185,6 +190,6 @@ export default function App() { Admin-Zentrale - + ); } diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index 8d4406d..82dd123 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -163,7 +163,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { title: projectForm.title || "Unbenanntes Projekt", status: projectForm.status || "Konzept", progress: projectForm.progress || 0, - description: projectForm.description || "" + description: projectForm.description || "", + imageUrl: projectForm.imageUrl || "", + spotifyPlaylistId: projectForm.spotifyPlaylistId || "" }; updatedProjects.push(newProj); } else { @@ -1288,7 +1290,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {