Compare commits
No commits in common. "main" and "codex/author-card-feature-suite" have entirely different histories.
main
...
codex/auth
12 changed files with 11 additions and 6057 deletions
|
|
@ -1,34 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
# This label must point to an isolated runner that has Node.js, Git,
|
|
||||||
# the Docker CLI, and access to a Docker/Podman daemon.
|
|
||||||
runs-on: docker-build
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: https://data.forgejo.org/actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: https://data.forgejo.org/actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
- name: Build and test
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build container image
|
|
||||||
run: docker build --tag webstack-author:ci .
|
|
||||||
|
|
@ -6,8 +6,8 @@ WORKDIR /app
|
||||||
# Copy dependency manifests
|
# Copy dependency manifests
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install exactly the dependency versions recorded in package-lock.json
|
# Install all dependencies
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
# Copy full application codebase
|
# Copy full application codebase
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
@ -23,8 +23,8 @@ WORKDIR /app
|
||||||
# Copy configuration files
|
# Copy configuration files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install exactly the locked production dependencies
|
# Install only production dependencies (Express, dotenv, @google/genai)
|
||||||
RUN npm ci --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Copy compiled build resources from builder stage
|
# Copy compiled build resources from builder stage
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -12,7 +12,6 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp
|
||||||
- Buchreihen mit Serienname und automatisch sortierter Bandnummer
|
- Buchreihen mit Serienname und automatisch sortierter Bandnummer
|
||||||
- getrennte KDP-Links für E-Book und Taschenbuch
|
- getrennte KDP-Links für E-Book und Taschenbuch
|
||||||
- optionaler, lokal gehosteter PDF-Leseproben-Download pro Buch
|
- optionaler, lokal gehosteter PDF-Leseproben-Download pro Buch
|
||||||
- optionaler Kurzgeschichten- und Download-Bereich mit PDF und ePUB
|
|
||||||
- Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist
|
- Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist
|
||||||
- Lokaler Bild-Upload über den Adminbereich
|
- Lokaler Bild-Upload über den Adminbereich
|
||||||
- Verwaltung von Impressum, Datenschutzerklärung und weiteren Rechtstexten
|
- Verwaltung von Impressum, Datenschutzerklärung und weiteren Rechtstexten
|
||||||
|
|
@ -178,14 +177,6 @@ Bücher können optional einem Seriennamen und einer Bandnummer zugeordnet werde
|
||||||
|
|
||||||
Pro Buch kann im Adminbereich eine PDF-Leseprobe mit maximal 10 MB hochgeladen werden. Der Server prüft Dateiendung, MIME-Uploadformat und PDF-Dateikopf und liefert die Datei als Download mit `nosniff` aus. Ohne hinterlegte Datei erscheint kein Leseproben-Button. PDF-Dateien liegen getrennt von Bildern unter `data/downloads/` und müssen daher in Backups eingeschlossen werden.
|
Pro Buch kann im Adminbereich eine PDF-Leseprobe mit maximal 10 MB hochgeladen werden. Der Server prüft Dateiendung, MIME-Uploadformat und PDF-Dateikopf und liefert die Datei als Download mit `nosniff` aus. Ohne hinterlegte Datei erscheint kein Leseproben-Button. PDF-Dateien liegen getrennt von Bildern unter `data/downloads/` und müssen daher in Backups eingeschlossen werden.
|
||||||
|
|
||||||
## Kurzgeschichten und Downloads
|
|
||||||
|
|
||||||
Jedes Profil kann einen eigenen optionalen Download-Bereich pflegen. Einträge bestehen aus Titel, kurzer Markdown-Beschreibung, optionalem Veröffentlichungsdatum, optionalem Bild sowie einer PDF- und/oder ePUB-Datei. Ein Eintrag ohne verfügbare Datei wird öffentlich nicht angezeigt; ohne Einträge verschwinden der gesamte Abschnitt und sein Navigationslink.
|
|
||||||
|
|
||||||
Öffentlich erscheinen zunächst höchstens drei Karten. Bei weiteren Einträgen können Besucher mit „Alle Kurzgeschichten anzeigen“ die vollständige Liste einblenden und anschließend wieder einklappen. Die Reihenfolge wird im Adminbereich explizit über Hoch-/Runter-Aktionen gepflegt; neu angelegte Downloads stehen zunächst oben.
|
|
||||||
|
|
||||||
PDF und ePUB werden getrennt validiert, lokal unter `data/downloads/` gespeichert und direkt als Download ausgeliefert. Beide Formate sind auf 10 MB begrenzt. Der Server prüft bei PDF den Dateikopf und bei ePUB die ZIP-/ePUB-Struktur. Ersetzte oder gelöschte Dateien werden entfernt, sobald kein veröffentlichter Eintrag mehr auf sie verweist.
|
|
||||||
|
|
||||||
## Teilbare Detailansichten
|
## Teilbare Detailansichten
|
||||||
|
|
||||||
Buch- und Projektmodale besitzen adressierbare URLs über `?book=<id>` beziehungsweise `?project=<id>`. Auf geeigneten Mobilgeräten öffnet „Teilen“ den nativen Teilen-Dialog; andernfalls wird die aktuelle URL in die Zwischenablage kopiert. Die URL enthält nur die ID innerhalb des aktuell aufgerufenen Profils und ermöglicht keinen Zugriff auf andere Profile.
|
Buch- und Projektmodale besitzen adressierbare URLs über `?book=<id>` beziehungsweise `?project=<id>`. Auf geeigneten Mobilgeräten öffnet „Teilen“ den nativen Teilen-Dialog; andernfalls wird die aktuelle URL in die Zwischenablage kopiert. Die URL enthält nur die ID innerhalb des aktuell aufgerufenen Profils und ermöglicht keinen Zugriff auf andere Profile.
|
||||||
|
|
@ -233,7 +224,6 @@ Die Integrationstests verwenden ein temporäres Datenverzeichnis und einen kurzl
|
||||||
- Revisionskonflikte und serverseitige Inhaltsvalidierung,
|
- Revisionskonflikte und serverseitige Inhaltsvalidierung,
|
||||||
- gültige und manipulierte Bild-Uploads,
|
- gültige und manipulierte Bild-Uploads,
|
||||||
- Löschung unbenutzter sowie Schutz referenzierter Uploads,
|
- Löschung unbenutzter sowie Schutz referenzierter Uploads,
|
||||||
- validierte PDF-/ePUB-Uploads und Schutz referenzierter Download-Dateien,
|
|
||||||
- wesentliche Sicherheitsheader.
|
- wesentliche Sicherheitsheader.
|
||||||
|
|
||||||
## Hinweise zur Aktualisierung
|
## Hinweise zur Aktualisierung
|
||||||
|
|
|
||||||
5607
package-lock.json
generated
5607
package-lock.json
generated
File diff suppressed because it is too large
Load diff
88
server.ts
88
server.ts
|
|
@ -5,7 +5,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { GoogleGenAI } from "@google/genai";
|
import { GoogleGenAI } from "@google/genai";
|
||||||
import { defaultAuthorData } from "./src/defaultData.js";
|
import { defaultAuthorData } from "./src/defaultData.js";
|
||||||
import { AuthorData, AuthorProfile, CustomSectionLink, DownloadPublication, PortfolioTheme, PublicAuthorData } from "./src/types.js";
|
import { AuthorData, AuthorProfile, CustomSectionLink, PortfolioTheme, PublicAuthorData } from "./src/types.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
@ -47,7 +47,6 @@ const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||||
const MAX_PDF_BYTES = 10 * 1024 * 1024;
|
const MAX_PDF_BYTES = 10 * 1024 * 1024;
|
||||||
const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i;
|
const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i;
|
||||||
const SAFE_PDF_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.pdf$/i;
|
const SAFE_PDF_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.pdf$/i;
|
||||||
const SAFE_DOWNLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:pdf|epub)$/i;
|
|
||||||
|
|
||||||
interface SessionRecord {
|
interface SessionRecord {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
|
@ -535,7 +534,6 @@ function validateProfile(profile: unknown): string | null {
|
||||||
const value = profile as AuthorProfile;
|
const value = profile as AuthorProfile;
|
||||||
if (!Array.isArray(value.books) || value.books.length > 500) return "Die Bücherliste ist ungültig oder zu groß.";
|
if (!Array.isArray(value.books) || value.books.length > 500) return "Die Bücherliste ist ungültig oder zu groß.";
|
||||||
if (!Array.isArray(value.projects) || value.projects.length > 200) return "Die Projektliste ist ungültig oder zu groß.";
|
if (!Array.isArray(value.projects) || value.projects.length > 200) return "Die Projektliste ist ungültig oder zu groß.";
|
||||||
if (value.downloads !== undefined && (!Array.isArray(value.downloads) || value.downloads.length > 200)) return "Die Downloadliste ist ungültig oder zu groß.";
|
|
||||||
const requiredStrings: Array<[unknown, string, number]> = [
|
const requiredStrings: Array<[unknown, string, number]> = [
|
||||||
[value.name, "Name", 200], [value.bio, "Biografie", 50_000],
|
[value.name, "Name", 200], [value.bio, "Biografie", 50_000],
|
||||||
[value.heroTitle, "Hero-Titel", 500], [value.heroSubtitle, "Hero-Untertitel", 500],
|
[value.heroTitle, "Hero-Titel", 500], [value.heroSubtitle, "Hero-Untertitel", 500],
|
||||||
|
|
@ -569,17 +567,6 @@ function validateProfile(profile: unknown): string | null {
|
||||||
return "Mindestens ein Buch enthält ungültige Werte.";
|
return "Mindestens ein Buch enthält ungültige Werte.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const download of value.downloads || []) {
|
|
||||||
if (!download || typeof download.id !== "string" || download.id.length > 200 ||
|
|
||||||
typeof download.title !== "string" || !download.title.trim() || download.title.length > 500 ||
|
|
||||||
typeof download.description !== "string" || download.description.length > 50_000 ||
|
|
||||||
!isSafeContentUrl(download.imageUrl) ||
|
|
||||||
(download.pdfUrl !== undefined && download.pdfUrl !== "" && (typeof download.pdfUrl !== "string" || !download.pdfUrl.startsWith("/downloads/") || !SAFE_PDF_NAME.test(path.basename(download.pdfUrl)))) ||
|
|
||||||
(download.epubUrl !== undefined && download.epubUrl !== "" && (typeof download.epubUrl !== "string" || !download.epubUrl.startsWith("/downloads/") || !/\.epub$/i.test(path.basename(download.epubUrl)))) ||
|
|
||||||
(download.publishedAt !== undefined && (typeof download.publishedAt !== "string" || download.publishedAt.length > 50))) {
|
|
||||||
return "Mindestens ein Download enthält ungültige Werte.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -607,8 +594,7 @@ function isUploadReferenced(url: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDownloadReferenced(url: string): boolean {
|
function isDownloadReferenced(url: string): boolean {
|
||||||
return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url) ||
|
return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url));
|
||||||
(dbCache[key].downloads || []).some((download: DownloadPublication) => download.pdfUrl === url || download.epubUrl === url));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function seoMeta(req: express.Request, scriptNonce?: string): string {
|
function seoMeta(req: express.Request, scriptNonce?: string): string {
|
||||||
|
|
@ -975,49 +961,6 @@ app.post("/api/admin/upload-sample", verifySession, verifySameOrigin, async (req
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/upload-publication", verifySession, verifySameOrigin, async (req, res) => {
|
|
||||||
const sessionId = res.locals.sessionId as string;
|
|
||||||
const rate = rateRecord(uploadRequests, sessionId, UPLOAD_WINDOW_MS);
|
|
||||||
if (rate.count >= UPLOAD_MAX_REQUESTS) {
|
|
||||||
res.status(429).json({ error: "Das stündliche Upload-Limit ist erreicht." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rate.count += 1;
|
|
||||||
const { fileName, base64Data, format } = req.body;
|
|
||||||
if (typeof fileName !== "string" || typeof base64Data !== "string" || (format !== "pdf" && format !== "epub")) {
|
|
||||||
res.status(400).json({ error: "Dateiname, Datei und Format sind erforderlich." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (path.extname(fileName).toLowerCase() !== `.${format}`) {
|
|
||||||
res.status(400).json({ error: "Dateiendung und gewähltes Format stimmen nicht überein." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const match = base64Data.match(/^data:(?:[^;]+)?;base64,([a-zA-Z0-9+/]+={0,2})$/);
|
|
||||||
if (!match) {
|
|
||||||
res.status(400).json({ error: "Das Uploadformat ist ungültig." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const buffer = Buffer.from(match[1], "base64");
|
|
||||||
if (buffer.length === 0 || buffer.length > MAX_PDF_BYTES) {
|
|
||||||
res.status(413).json({ error: "Die Datei darf maximal 10 MB groß sein." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isPdf = buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
|
||||||
const isEpub = buffer.length > 58 && buffer[0] === 0x50 && buffer[1] === 0x4b && buffer.includes(Buffer.from("application/epub+zip"));
|
|
||||||
if ((format === "pdf" && !isPdf) || (format === "epub" && !isEpub)) {
|
|
||||||
res.status(415).json({ error: `Die Datei besitzt keine gültige ${format.toUpperCase()}-Struktur.` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.${format}`;
|
|
||||||
await fs.writeFile(path.join(DOWNLOAD_DIR, generatedName), buffer, { flag: "wx" });
|
|
||||||
res.status(201).json({ success: true, url: `/downloads/${generatedName}`, size: buffer.length, format });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Publication upload failed:", error);
|
|
||||||
res.status(500).json({ error: "Die Datei konnte nicht gespeichert werden." });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (req, res) => {
|
app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (req, res) => {
|
||||||
const name = req.params.name;
|
const name = req.params.name;
|
||||||
if (!SAFE_PDF_NAME.test(name) || path.basename(name) !== name) {
|
if (!SAFE_PDF_NAME.test(name) || path.basename(name) !== name) {
|
||||||
|
|
@ -1041,29 +984,6 @@ app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (r
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/api/admin/publications/:name", verifySession, verifySameOrigin, async (req, res) => {
|
|
||||||
const name = req.params.name;
|
|
||||||
if (!SAFE_DOWNLOAD_NAME.test(name) || path.basename(name) !== name) {
|
|
||||||
res.status(400).json({ error: "Ungültiger Dateiname." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = `/downloads/${name}`;
|
|
||||||
if (isDownloadReferenced(url)) {
|
|
||||||
res.status(409).json({ error: "Die Datei wird noch in einem veröffentlichten Eintrag verwendet." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await fs.unlink(path.join(DOWNLOAD_DIR, name));
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.code === "ENOENT") {
|
|
||||||
res.status(404).json({ error: "Die Datei wurde nicht gefunden." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: "Die Datei konnte nicht gelöscht werden." });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure Vite middleware or static serve
|
// Configure Vite middleware or static serve
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
// Serve the dynamic uploads directory statically
|
// Serve the dynamic uploads directory statically
|
||||||
|
|
@ -1087,7 +1007,7 @@ async function startServer() {
|
||||||
}));
|
}));
|
||||||
app.use("/downloads", (req, res, next) => {
|
app.use("/downloads", (req, res, next) => {
|
||||||
const requestedName = path.basename(req.path);
|
const requestedName = path.basename(req.path);
|
||||||
if (!SAFE_DOWNLOAD_NAME.test(requestedName)) {
|
if (!SAFE_PDF_NAME.test(requestedName)) {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1097,7 +1017,7 @@ async function startServer() {
|
||||||
dotfiles: "deny",
|
dotfiles: "deny",
|
||||||
fallthrough: false,
|
fallthrough: false,
|
||||||
setHeaders: (res, filePath) => {
|
setHeaders: (res, filePath) => {
|
||||||
res.setHeader("Content-Type", filePath.toLowerCase().endsWith(".epub") ? "application/epub+zip" : "application/pdf");
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
|
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,4 @@ export const adminApi = {
|
||||||
request<{ success: true; url: string }>("/api/admin/upload-sample", jsonPost({ fileName, base64Data })),
|
request<{ success: true; url: string }>("/api/admin/upload-sample", jsonPost({ fileName, base64Data })),
|
||||||
deleteSample: (name: string) =>
|
deleteSample: (name: string) =>
|
||||||
request<{ success: true }>(`/api/admin/samples/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
request<{ success: true }>(`/api/admin/samples/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
||||||
uploadPublication: (fileName: string, base64Data: string, format: "pdf" | "epub") =>
|
|
||||||
request<{ success: true; url: string }>("/api/admin/upload-publication", jsonPost({ fileName, base64Data, format })),
|
|
||||||
deletePublication: (name: string) =>
|
|
||||||
request<{ success: true }>(`/api/admin/publications/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@ import {
|
||||||
Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles,
|
Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles,
|
||||||
BookOpen, Compass, Clipboard, Eye, Music, LogOut, ArrowLeftRight,
|
BookOpen, Compass, Clipboard, Eye, Music, LogOut, ArrowLeftRight,
|
||||||
Palette, Type, Image as ImageIcon, Globe, Tag,
|
Palette, Type, Image as ImageIcon, Globe, Tag,
|
||||||
Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight, Download
|
Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import ImagePicker from "./ImagePicker";
|
import ImagePicker from "./ImagePicker";
|
||||||
import AdminLogin from "./admin/AdminLogin";
|
import AdminLogin from "./admin/AdminLogin";
|
||||||
import SamplePdfPicker from "./admin/SamplePdfPicker";
|
import SamplePdfPicker from "./admin/SamplePdfPicker";
|
||||||
import DownloadsManager from "./admin/DownloadsManager";
|
|
||||||
import { adminApi, AdminApiError } from "../api/adminApi";
|
import { adminApi, AdminApiError } from "../api/adminApi";
|
||||||
|
|
||||||
interface AdminPanelProps {
|
interface AdminPanelProps {
|
||||||
|
|
@ -31,7 +30,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
const [activeProfile, setActiveProfile] = useState<"scifi" | "erotica" | "clara" | "renee">("scifi");
|
const [activeProfile, setActiveProfile] = useState<"scifi" | "erotica" | "clara" | "renee">("scifi");
|
||||||
|
|
||||||
// Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects" | "legal")
|
// Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects" | "legal")
|
||||||
const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "downloads" | "legal">("profile");
|
const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "legal">("profile");
|
||||||
|
|
||||||
// Editing forms state
|
// Editing forms state
|
||||||
const [editingBookId, setEditingBookId] = useState<string | null>(null);
|
const [editingBookId, setEditingBookId] = useState<string | null>(null);
|
||||||
|
|
@ -564,13 +563,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
<BookOpen className="w-4 h-4" />
|
<BookOpen className="w-4 h-4" />
|
||||||
<span>Bücherregal ({profile.books.length})</span>
|
<span>Bücherregal ({profile.books.length})</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("downloads")}
|
|
||||||
className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "downloads" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
<span>Downloads ({(profile.downloads || []).length})</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveTab("legal"); setEditingLegalId(null); setLegalForm({}); }}
|
onClick={() => { setActiveTab("legal"); setEditingLegalId(null); setLegalForm({}); }}
|
||||||
className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "legal" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
|
className={`px-3 py-2.5 rounded-xl text-left text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${activeTab === "legal" ? "bg-slate-800 border-l-4 border-indigo-500 text-white" : "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
|
||||||
|
|
@ -1767,25 +1759,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "downloads" && (
|
|
||||||
<DownloadsManager
|
|
||||||
downloads={profile.downloads || []}
|
|
||||||
title={profile.downloadsTitle || "Kurzgeschichten & Downloads"}
|
|
||||||
subtitle={profile.downloadsSubtitle || "Kostenlose Geschichten zum Herunterladen."}
|
|
||||||
loading={loading}
|
|
||||||
onError={(message) => showNotice(message)}
|
|
||||||
onSave={async (downloads, downloadsTitle, downloadsSubtitle) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await persistProfile(activeProfile, { ...profile, downloads, downloadsTitle, downloadsSubtitle });
|
|
||||||
showNotice("Download-Bereich gespeichert.", "success");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TAB: LEGAL DOCUMENTS (IMPRESSUM & DATENSCHUTZ) */}
|
{/* TAB: LEGAL DOCUMENTS (IMPRESSUM & DATENSCHUTZ) */}
|
||||||
{activeTab === "legal" && (
|
{activeTab === "legal" && (
|
||||||
<div className="p-6 md:p-8 space-y-8">
|
<div className="p-6 md:p-8 space-y-8">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types";
|
import { Book, Project, AuthorProfile, LegalDocument, PortfolioTheme } from "../types";
|
||||||
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon, Mail, Instagram, AtSign, Share2, FileText, MessageCircle, Download, ChevronDown, ChevronUp } from "lucide-react";
|
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon, Mail, Instagram, AtSign, Share2, FileText, MessageCircle } from "lucide-react";
|
||||||
import SpotlightSection from "./SpotlightSection";
|
import SpotlightSection from "./SpotlightSection";
|
||||||
import LegalModal from "./LegalModal";
|
import LegalModal from "./LegalModal";
|
||||||
import ProjectDetailMedia from "./ProjectDetailMedia";
|
import ProjectDetailMedia from "./ProjectDetailMedia";
|
||||||
|
|
@ -36,7 +36,6 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
||||||
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
||||||
const [shareNotice, setShareNotice] = useState("");
|
const [shareNotice, setShareNotice] = useState("");
|
||||||
const [showAllDownloads, setShowAllDownloads] = useState(false);
|
|
||||||
|
|
||||||
const relevantLegalDocs = legalDocuments;
|
const relevantLegalDocs = legalDocuments;
|
||||||
|
|
||||||
|
|
@ -58,8 +57,6 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
const seriesComparison = left.book.seriesName.localeCompare(right.book.seriesName, "de");
|
const seriesComparison = left.book.seriesName.localeCompare(right.book.seriesName, "de");
|
||||||
return seriesComparison || Number(left.book.seriesNumber || 0) - Number(right.book.seriesNumber || 0) || left.index - right.index;
|
return seriesComparison || Number(left.book.seriesNumber || 0) - Number(right.book.seriesNumber || 0) || left.index - right.index;
|
||||||
}).map(({ book }) => book);
|
}).map(({ book }) => book);
|
||||||
const availableDownloads = (data.downloads || []).filter((entry) => entry.pdfUrl || entry.epubUrl);
|
|
||||||
const visibleDownloads = showAllDownloads ? availableDownloads : availableDownloads.slice(0, 3);
|
|
||||||
|
|
||||||
const syncDetailFromUrl = () => {
|
const syncDetailFromUrl = () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -103,12 +100,6 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDownloads = () => {
|
|
||||||
const isCollapsing = showAllDownloads;
|
|
||||||
setShowAllDownloads((current) => !current);
|
|
||||||
if (isCollapsing) window.requestAnimationFrame(() => document.getElementById("downloads")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-h-screen text-slate-100 ${fontClass} selection:bg-cyan-500 selection:text-white overflow-x-hidden relative`}
|
className={`min-h-screen text-slate-100 ${fontClass} selection:bg-cyan-500 selection:text-white overflow-x-hidden relative`}
|
||||||
|
|
@ -176,11 +167,6 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
>
|
>
|
||||||
🪐 {data.booksTitle || "Bücherregal"}
|
🪐 {data.booksTitle || "Bücherregal"}
|
||||||
</a>
|
</a>
|
||||||
{availableDownloads.length > 0 && (
|
|
||||||
<a href="#downloads" className="px-4 py-2 rounded-full border border-slate-800 bg-slate-900/60 hover:border-cyan-400 hover:text-cyan-400 transition-all text-slate-300 uppercase tracking-wider">
|
|
||||||
⬇ {data.downloadsTitle || "Downloads"}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -366,38 +352,6 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{availableDownloads.length > 0 && (
|
|
||||||
<section id="downloads" className="space-y-8 scroll-mt-6">
|
|
||||||
<div className="border-b border-slate-800 pb-4">
|
|
||||||
<h2 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-white"><Download className="h-7 w-7" style={{ color: accentColor }} /><span>{data.downloadsTitle || "Kurzgeschichten & Downloads"}</span></h2>
|
|
||||||
{data.downloadsSubtitle && <p className="mt-1 text-sm text-slate-400">{data.downloadsSubtitle}</p>}
|
|
||||||
</div>
|
|
||||||
<div id="download-grid" className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{visibleDownloads.map((entry) => (
|
|
||||||
<article key={entry.id} className="flex flex-col overflow-hidden rounded-2xl border shadow-lg" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
|
|
||||||
{entry.imageUrl && <img src={entry.imageUrl} alt={`Motiv zu ${entry.title}`} className="block h-auto w-full" loading="lazy" referrerPolicy="no-referrer" />}
|
|
||||||
<div className="flex flex-1 flex-col p-6">
|
|
||||||
{entry.publishedAt && <p className="mb-2 text-[10px] font-mono uppercase tracking-widest text-slate-500">{entry.publishedAt}</p>}
|
|
||||||
<h3 className="text-xl font-bold text-white">{entry.title}</h3>
|
|
||||||
{entry.description && <div className="mt-3 flex-1 text-sm leading-relaxed text-slate-300 markdown-body"><Markdown>{entry.description}</Markdown></div>}
|
|
||||||
<div className="mt-6 flex flex-col gap-2 sm:flex-row">
|
|
||||||
{entry.pdfUrl && <a href={entry.pdfUrl} className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-xs font-bold text-white hover:bg-white/10" style={{ borderColor: `${accentColor}60` }}><FileText className="h-4 w-4" /><span>PDF</span><Download className="h-3.5 w-3.5" /></a>}
|
|
||||||
{entry.epubUrl && <a href={entry.epubUrl} className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-xs font-bold text-white hover:bg-white/10" style={{ borderColor: `${secondaryColor}60` }}><BookOpen className="h-4 w-4" /><span>ePUB</span><Download className="h-3.5 w-3.5" /></a>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{availableDownloads.length > 3 && (
|
|
||||||
<div className="text-center">
|
|
||||||
<button type="button" onClick={toggleDownloads} aria-expanded={showAllDownloads} aria-controls="download-grid" className="inline-flex items-center gap-2 rounded-xl border border-slate-700 bg-slate-900 px-5 py-3 text-sm font-semibold text-white hover:bg-slate-800">
|
|
||||||
{showAllDownloads ? <><ChevronUp className="h-4 w-4" /><span>Weniger anzeigen</span></> : <><ChevronDown className="h-4 w-4" /><span>Alle Kurzgeschichten anzeigen ({availableDownloads.length})</span></>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Section 4: Custom Highlight Section if present */}
|
{/* Section 4: Custom Highlight Section if present */}
|
||||||
{(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
|
{(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
|
||||||
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
|
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ArrowDown, ArrowUp, Edit2, Plus, Save, Trash2 } from "lucide-react";
|
|
||||||
import type { DownloadPublication } from "../../types";
|
|
||||||
import { adminApi } from "../../api/adminApi";
|
|
||||||
import ImagePicker from "../ImagePicker";
|
|
||||||
import PublicationFilePicker from "./PublicationFilePicker";
|
|
||||||
|
|
||||||
interface DownloadsManagerProps {
|
|
||||||
downloads: DownloadPublication[];
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
loading: boolean;
|
|
||||||
onSave: (downloads: DownloadPublication[], title: string, subtitle: string) => Promise<void>;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyForm = (): Partial<DownloadPublication> => ({ title: "", description: "", imageUrl: "", pdfUrl: "", epubUrl: "", publishedAt: "" });
|
|
||||||
|
|
||||||
async function deleteUnreferencedFiles(urls: Array<string | undefined>) {
|
|
||||||
await Promise.all(urls.filter(Boolean).map(async (url) => {
|
|
||||||
const name = url!.split("/").pop();
|
|
||||||
if (name) await adminApi.deletePublication(name).catch(() => undefined);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DownloadsManager({ downloads, title, subtitle, loading, onSave, onError }: DownloadsManagerProps) {
|
|
||||||
const [sectionTitle, setSectionTitle] = useState(title);
|
|
||||||
const [sectionSubtitle, setSectionSubtitle] = useState(subtitle);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [form, setForm] = useState<Partial<DownloadPublication>>(emptyForm());
|
|
||||||
|
|
||||||
useEffect(() => setSectionTitle(title), [title]);
|
|
||||||
useEffect(() => setSectionSubtitle(subtitle), [subtitle]);
|
|
||||||
|
|
||||||
const persist = async (nextDownloads: DownloadPublication[]) => {
|
|
||||||
try {
|
|
||||||
await onSave(nextDownloads, sectionTitle, sectionSubtitle);
|
|
||||||
} catch (error) {
|
|
||||||
onError(error instanceof Error ? error.message : "Die Downloads konnten nicht gespeichert werden.");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEntry = async () => {
|
|
||||||
if (!form.title?.trim()) {
|
|
||||||
onError("Bitte einen Titel für den Download eintragen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!form.pdfUrl && !form.epubUrl) {
|
|
||||||
onError("Bitte mindestens eine PDF- oder ePUB-Datei hochladen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const previous = editingId && editingId !== "new" ? downloads.find((item) => item.id === editingId) : undefined;
|
|
||||||
const entry: DownloadPublication = {
|
|
||||||
id: editingId === "new" ? `download_${Date.now()}` : editingId!,
|
|
||||||
title: form.title.trim(),
|
|
||||||
description: form.description || "",
|
|
||||||
imageUrl: form.imageUrl || "",
|
|
||||||
pdfUrl: form.pdfUrl || "",
|
|
||||||
epubUrl: form.epubUrl || "",
|
|
||||||
publishedAt: form.publishedAt || "",
|
|
||||||
};
|
|
||||||
const next = editingId === "new" ? [entry, ...downloads] : downloads.map((item) => item.id === editingId ? entry : item);
|
|
||||||
await persist(next);
|
|
||||||
await deleteUnreferencedFiles([
|
|
||||||
previous?.pdfUrl && previous.pdfUrl !== entry.pdfUrl ? previous.pdfUrl : undefined,
|
|
||||||
previous?.epubUrl && previous.epubUrl !== entry.epubUrl ? previous.epubUrl : undefined,
|
|
||||||
]);
|
|
||||||
setEditingId(null);
|
|
||||||
setForm(emptyForm());
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async (entry: DownloadPublication) => {
|
|
||||||
if (!confirm(`„${entry.title}“ wirklich löschen?`)) return;
|
|
||||||
await persist(downloads.filter((item) => item.id !== entry.id));
|
|
||||||
await deleteUnreferencedFiles([entry.pdfUrl, entry.epubUrl]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const move = async (index: number, direction: -1 | 1) => {
|
|
||||||
const target = index + direction;
|
|
||||||
if (target < 0 || target >= downloads.length) return;
|
|
||||||
const next = [...downloads];
|
|
||||||
[next[index], next[target]] = [next[target], next[index]];
|
|
||||||
await persist(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingId) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
|
||||||
<h2 className="text-lg font-bold text-white">{editingId === "new" ? "Neuen Download anlegen" : "Download bearbeiten"}</h2>
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-1 md:col-span-2"><label className="text-xs font-mono uppercase text-slate-400">Titel</label><input value={form.title || ""} onChange={(event) => setForm({ ...form, title: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
|
|
||||||
<div className="space-y-1 md:col-span-2"><label className="text-xs font-mono uppercase text-slate-400">Kurze Beschreibung</label><textarea rows={4} value={form.description || ""} onChange={(event) => setForm({ ...form, description: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
|
|
||||||
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">Veröffentlichungsdatum (optional)</label><input type="date" value={form.publishedAt || ""} onChange={(event) => setForm({ ...form, publishedAt: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div>
|
|
||||||
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">Optionales Bild</label><ImagePicker value={form.imageUrl || ""} onChange={(url) => setForm({ ...form, imageUrl: url })} label="Download-Bild" /></div>
|
|
||||||
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">PDF-Datei</label><PublicationFilePicker format="pdf" value={form.pdfUrl || ""} onChange={(url) => setForm({ ...form, pdfUrl: url })} /></div>
|
|
||||||
<div className="space-y-1"><label className="text-xs font-mono uppercase text-slate-400">ePUB-Datei</label><PublicationFilePicker format="epub" value={form.epubUrl || ""} onChange={(url) => setForm({ ...form, epubUrl: url })} /></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 border-t border-slate-800 pt-4"><button type="button" disabled={loading} onClick={saveEntry} className="rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white"><Save className="mr-1 inline h-4 w-4" />Speichern</button><button type="button" onClick={() => { setEditingId(null); setForm(emptyForm()); }} className="rounded-lg bg-slate-800 px-4 py-2 text-xs text-slate-300">Abbrechen</button></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-4 rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3"><div><h2 className="text-lg font-bold text-white">Kurzgeschichten & Downloads</h2><p className="text-xs text-slate-400">Die ersten drei Einträge werden öffentlich sofort angezeigt.</p></div><button type="button" onClick={() => { setEditingId("new"); setForm(emptyForm()); }} className="rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white"><Plus className="mr-1 inline h-4 w-4" />Neuer Download</button></div>
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"><div><label className="text-xs font-mono uppercase text-slate-400">Abschnittstitel</label><input value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} className="mt-1 w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div><div><label className="text-xs font-mono uppercase text-slate-400">Untertitel</label><input value={sectionSubtitle} onChange={(event) => setSectionSubtitle(event.target.value)} className="mt-1 w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" /></div></div>
|
|
||||||
<button type="button" disabled={loading} onClick={() => persist(downloads)} className="rounded-lg bg-indigo-600 px-4 py-2 text-xs font-bold text-white">Überschriften speichern</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{downloads.length === 0 && <p className="rounded-xl border border-dashed border-slate-800 p-8 text-center text-sm text-slate-500">Noch keine Downloads hinterlegt.</p>}
|
|
||||||
{downloads.map((entry, index) => (
|
|
||||||
<div key={entry.id} className="flex items-center gap-4 rounded-xl border border-slate-800 bg-slate-900 p-4">
|
|
||||||
{entry.imageUrl && <img src={entry.imageUrl} alt="" className="h-16 w-16 rounded-lg object-cover" />}
|
|
||||||
<div className="min-w-0 flex-1"><h3 className="truncate font-bold text-white">{entry.title}</h3><p className="line-clamp-1 text-xs text-slate-400">{entry.description}</p><p className="mt-1 text-[10px] text-slate-500">{entry.pdfUrl ? "PDF " : ""}{entry.epubUrl ? "ePUB" : ""}</p></div>
|
|
||||||
<div className="flex items-center gap-1"><button type="button" disabled={index === 0} onClick={() => move(index, -1)} className="p-2 text-slate-400 disabled:opacity-20" aria-label="Nach oben"><ArrowUp className="h-4 w-4" /></button><button type="button" disabled={index === downloads.length - 1} onClick={() => move(index, 1)} className="p-2 text-slate-400 disabled:opacity-20" aria-label="Nach unten"><ArrowDown className="h-4 w-4" /></button><button type="button" onClick={() => { setEditingId(entry.id); setForm(entry); }} className="p-2 text-slate-400 hover:text-white" aria-label="Bearbeiten"><Edit2 className="h-4 w-4" /></button><button type="button" onClick={() => remove(entry)} className="p-2 text-rose-400" aria-label="Löschen"><Trash2 className="h-4 w-4" /></button></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { FileText, Loader2, Trash2, Upload } from "lucide-react";
|
|
||||||
import { adminApi, AdminApiError } from "../../api/adminApi";
|
|
||||||
|
|
||||||
interface PublicationFilePickerProps {
|
|
||||||
format: "pdf" | "epub";
|
|
||||||
value: string;
|
|
||||||
onChange: (url: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicationFilePicker({ format, value, onChange }: PublicationFilePickerProps) {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const upload = (file?: File) => {
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.name.toLowerCase().endsWith(`.${format}`)) {
|
|
||||||
setError(`Bitte eine ${format.toUpperCase()}-Datei auswählen.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
setError("Die Datei darf maximal 10 MB groß sein.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async () => {
|
|
||||||
try {
|
|
||||||
const result = await adminApi.uploadPublication(file.name, String(reader.result), format);
|
|
||||||
onChange(result.url);
|
|
||||||
} catch (uploadError) {
|
|
||||||
setError(uploadError instanceof AdminApiError ? uploadError.message : "Upload fehlgeschlagen.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
setError("Die Datei konnte nicht gelesen werden.");
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 rounded-xl border border-slate-800 bg-slate-950/50 p-3">
|
|
||||||
<input ref={inputRef} type="file" accept={format === "pdf" ? "application/pdf,.pdf" : "application/epub+zip,.epub"} className="hidden" onChange={(event) => upload(event.target.files?.[0])} />
|
|
||||||
{value ? (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="min-w-0 truncate text-xs text-slate-300"><FileText className="mr-1 inline h-4 w-4" />{value.split("/").pop()}</span>
|
|
||||||
<button type="button" onClick={() => onChange("")} className="p-1.5 text-slate-400 hover:text-rose-400" aria-label={`${format.toUpperCase()} entfernen`}><Trash2 className="h-4 w-4" /></button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button type="button" disabled={loading} onClick={() => inputRef.current?.click()} className="flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-500 disabled:opacity-60">
|
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}<span>{format.toUpperCase()} hochladen</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{error && <p role="alert" className="text-xs text-rose-400">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
13
src/types.ts
13
src/types.ts
|
|
@ -32,16 +32,6 @@ export interface CustomSectionLink {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadPublication {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
pdfUrl?: string;
|
|
||||||
epubUrl?: string;
|
|
||||||
publishedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorProfile {
|
export interface AuthorProfile {
|
||||||
name: string;
|
name: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
|
|
@ -57,9 +47,6 @@ export interface AuthorProfile {
|
||||||
instagramUrl?: string;
|
instagramUrl?: string;
|
||||||
threadsUrl?: string;
|
threadsUrl?: string;
|
||||||
discordUrl?: string;
|
discordUrl?: string;
|
||||||
downloads?: DownloadPublication[];
|
|
||||||
downloadsTitle?: string;
|
|
||||||
downloadsSubtitle?: string;
|
|
||||||
|
|
||||||
// Domain & Path Routing
|
// Domain & Path Routing
|
||||||
customDomain?: string; // e.g., "annieslone.de, annie-slone.de"
|
customDomain?: string; // e.g., "annieslone.de, annie-slone.de"
|
||||||
|
|
|
||||||
|
|
@ -212,36 +212,7 @@ test("production hardening and public routing", async (t) => {
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.match(response.headers.get("content-disposition") || "", /attachment/);
|
assert.match(response.headers.get("content-disposition") || "", /attachment/);
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/upload-publication`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
|
||||||
body: JSON.stringify({ fileName: "geschichte.pdf", base64Data: validPdf, format: "pdf" }),
|
|
||||||
});
|
|
||||||
assert.equal(response.status, 201);
|
|
||||||
const publicationPdf = await response.json();
|
|
||||||
const epubBuffer = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(60), Buffer.from("application/epub+zip")]);
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/upload-publication`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
|
||||||
body: JSON.stringify({ fileName: "geschichte.epub", base64Data: `data:application/epub+zip;base64,${epubBuffer.toString("base64")}`, format: "epub" }),
|
|
||||||
});
|
|
||||||
assert.equal(response.status, 201);
|
|
||||||
const publicationEpub = await response.json();
|
|
||||||
response = await fetch(`${baseUrl}${publicationEpub.url}`);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
assert.equal(response.headers.get("content-type"), "application/epub+zip");
|
|
||||||
|
|
||||||
adminData.erotica.books[0].samplePdfUrl = sample.url;
|
adminData.erotica.books[0].samplePdfUrl = sample.url;
|
||||||
adminData.erotica.downloadsTitle = "Kostenlose Geschichten";
|
|
||||||
adminData.erotica.downloads = [{
|
|
||||||
id: "download_test",
|
|
||||||
title: "Testgeschichte",
|
|
||||||
description: "Eine kurze Beschreibung.",
|
|
||||||
imageUrl: "",
|
|
||||||
pdfUrl: publicationPdf.url,
|
|
||||||
epubUrl: publicationEpub.url,
|
|
||||||
publishedAt: "2026-08-18",
|
|
||||||
}];
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
|
@ -249,14 +220,8 @@ test("production hardening and public routing", async (t) => {
|
||||||
});
|
});
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
const sampleSave = await response.json();
|
const sampleSave = await response.json();
|
||||||
const downloadPublicResponse = await getWithHost(`${baseUrl}/api/author-data`, "annieslone.de");
|
|
||||||
const downloadPublicData = JSON.parse(downloadPublicResponse.body);
|
|
||||||
assert.equal(downloadPublicData.profile.downloads[0].title, "Testgeschichte");
|
|
||||||
assert.equal(downloadPublicData.profile.downloads[0].epubUrl, publicationEpub.url);
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
||||||
assert.equal(response.status, 409);
|
assert.equal(response.status, 409);
|
||||||
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationEpub.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
|
||||||
assert.equal(response.status, 409);
|
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}${uploaded.url}`);
|
response = await fetch(`${baseUrl}${uploaded.url}`);
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
|
|
@ -280,7 +245,6 @@ test("production hardening and public routing", async (t) => {
|
||||||
|
|
||||||
adminData.erotica.avatarUrl = "";
|
adminData.erotica.avatarUrl = "";
|
||||||
adminData.erotica.books[0].samplePdfUrl = "";
|
adminData.erotica.books[0].samplePdfUrl = "";
|
||||||
adminData.erotica.downloads = [];
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
||||||
|
|
@ -297,10 +261,6 @@ test("production hardening and public routing", async (t) => {
|
||||||
assert.equal(response.status, 404);
|
assert.equal(response.status, 404);
|
||||||
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
response = await fetch(`${baseUrl}/api/admin/samples/${path.basename(sample.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationPdf.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/publications/${path.basename(publicationEpub.url)}`, { method: "DELETE", headers: { origin: baseUrl, cookie } });
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
|
|
||||||
response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
|
response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue