Added legal notes function
This commit is contained in:
parent
99395d3091
commit
7b639941b3
10 changed files with 1055 additions and 20 deletions
24
server.ts
24
server.ts
|
|
@ -73,6 +73,12 @@ async function initDatabase(): Promise<AuthorData> {
|
|||
modified = true;
|
||||
}
|
||||
|
||||
// Ensure legalDocuments exists
|
||||
if (!parsed.legalDocuments || !Array.isArray(parsed.legalDocuments)) {
|
||||
parsed.legalDocuments = defaultAuthorData.legalDocuments || [];
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
await fs.writeFile(DATA_FILE, JSON.stringify(parsed, null, 2), "utf-8");
|
||||
}
|
||||
|
|
@ -177,6 +183,24 @@ app.post("/api/admin/save-profile", verifyToken, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 3b. Save legal documents (Impressum & Datenschutzerklärung)
|
||||
app.post("/api/admin/save-legal", verifyToken, async (req, res) => {
|
||||
const { legalDocuments } = req.body;
|
||||
if (!Array.isArray(legalDocuments)) {
|
||||
res.status(400).json({ error: "legalDocuments muss ein Array sein." });
|
||||
return;
|
||||
}
|
||||
|
||||
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." });
|
||||
} 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." });
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Creative AI Blurb Assistant for book blurb updates
|
||||
app.post("/api/admin/generate-blurb", verifyToken, async (req, res) => {
|
||||
const { title, genre, ideas, tone } = req.body;
|
||||
|
|
|
|||
|
|
@ -153,16 +153,17 @@ export default function App() {
|
|||
// Render active portfolio
|
||||
const renderActivePortfolio = () => {
|
||||
const activeSuite = getActiveSuite();
|
||||
const legalDocs = authorData.legalDocuments || [];
|
||||
if (activeSuite === "erotica") {
|
||||
return <EroticaPortfolio data={authorData.erotica} />;
|
||||
return <EroticaPortfolio data={authorData.erotica} legalDocuments={legalDocs} />;
|
||||
}
|
||||
if (activeSuite === "clara") {
|
||||
return <ClaraPortfolio data={authorData.clara} />;
|
||||
return <ClaraPortfolio data={authorData.clara} legalDocuments={legalDocs} />;
|
||||
}
|
||||
if (activeSuite === "renee") {
|
||||
return <ReneePortfolio data={authorData.renee} />;
|
||||
return <ReneePortfolio data={authorData.renee} legalDocuments={legalDocs} />;
|
||||
}
|
||||
return <SciFiPortfolio data={authorData.scifi} />;
|
||||
return <SciFiPortfolio data={authorData.scifi} legalDocuments={legalDocs} />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useState, useEffect, FormEvent } from "react";
|
||||
import { AuthorData, AuthorProfile, Book, Project } from "../types";
|
||||
import Markdown from "react-markdown";
|
||||
import { AuthorData, AuthorProfile, Book, Project, LegalDocument } from "../types";
|
||||
import {
|
||||
Lock, Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles,
|
||||
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
|
||||
} from "lucide-react";
|
||||
import ImagePicker from "./ImagePicker";
|
||||
|
||||
|
|
@ -24,8 +26,8 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
// Selected Profile state ("scifi" vs "erotica" vs "clara" vs "renee")
|
||||
const [activeProfile, setActiveProfile] = useState<"scifi" | "erotica" | "clara" | "renee">("scifi");
|
||||
|
||||
// Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects")
|
||||
const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects">("profile");
|
||||
// Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects" | "legal")
|
||||
const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "legal">("profile");
|
||||
|
||||
// Editing forms state
|
||||
const [editingBookId, setEditingBookId] = useState<string | null>(null);
|
||||
|
|
@ -35,6 +37,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
const [projectForm, setProjectForm] = useState<Partial<Project>>({});
|
||||
|
||||
// Legal Documents state
|
||||
const [editingLegalId, setEditingLegalId] = useState<string | null>(null);
|
||||
const [legalForm, setLegalForm] = useState<Partial<LegalDocument>>({});
|
||||
const [legalPreviewMode, setLegalPreviewMode] = useState<"edit" | "preview">("edit");
|
||||
|
||||
// AI Prompt Tool State
|
||||
const [aiTitle, setAiTitle] = useState("");
|
||||
const [aiGenre, setAiGenre] = useState("");
|
||||
|
|
@ -356,6 +363,120 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
}
|
||||
};
|
||||
|
||||
// --- LEGAL DOCUMENTS MANAGEMENT ---
|
||||
const handleSaveLegalDocument = async () => {
|
||||
if (!authorData) return;
|
||||
const currentDocs = authorData.legalDocuments || [];
|
||||
let updatedDocs = [...currentDocs];
|
||||
const assigned: ("scifi" | "erotica" | "clara" | "renee")[] =
|
||||
legalForm.assignedProfiles && legalForm.assignedProfiles.length > 0
|
||||
? legalForm.assignedProfiles
|
||||
: ["scifi", "erotica", "clara", "renee"];
|
||||
|
||||
if (editingLegalId === "new") {
|
||||
const newDoc: LegalDocument = {
|
||||
id: "legal_" + Date.now(),
|
||||
title: legalForm.title || "Rechtliches Dokument",
|
||||
type: legalForm.type || "impressum",
|
||||
content: legalForm.content || "",
|
||||
updatedAt: legalForm.updatedAt || new Date().toISOString().split("T")[0],
|
||||
assignedProfiles: assigned
|
||||
};
|
||||
updatedDocs.push(newDoc);
|
||||
} else {
|
||||
updatedDocs = updatedDocs.map(d =>
|
||||
d.id === editingLegalId
|
||||
? {
|
||||
...d,
|
||||
...legalForm,
|
||||
assignedProfiles: assigned,
|
||||
updatedAt: legalForm.updatedAt || new Date().toISOString().split("T")[0]
|
||||
} as LegalDocument
|
||||
: d
|
||||
);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/save-legal", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ legalDocuments: updatedDocs })
|
||||
});
|
||||
if (res.ok) {
|
||||
setAuthorData({ ...authorData, legalDocuments: updatedDocs });
|
||||
setEditingLegalId(null);
|
||||
setLegalForm({});
|
||||
} else {
|
||||
alert("Fehler beim Speichern des Dokuments.");
|
||||
}
|
||||
} catch {
|
||||
alert("Netzwerkfehler beim Speichern.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLegalDocument = async (docId: string) => {
|
||||
if (!authorData || !confirm("Möchten Sie dieses rechtliche Dokument wirklich löschen?")) return;
|
||||
const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/save-legal", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ legalDocuments: updatedDocs })
|
||||
});
|
||||
if (res.ok) {
|
||||
setAuthorData({ ...authorData, legalDocuments: updatedDocs });
|
||||
if (editingLegalId === docId) {
|
||||
setEditingLegalId(null);
|
||||
setLegalForm({});
|
||||
}
|
||||
} else {
|
||||
alert("Fehler beim Löschen des Dokuments.");
|
||||
}
|
||||
} catch {
|
||||
alert("Netzwerkfehler beim Löschen.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateLegalDocument = (doc: LegalDocument) => {
|
||||
setEditingLegalId("new");
|
||||
setLegalForm({
|
||||
title: `${doc.title} (Kopie)`,
|
||||
type: doc.type,
|
||||
content: doc.content,
|
||||
updatedAt: new Date().toISOString().split("T")[0],
|
||||
assignedProfiles: [...doc.assignedProfiles]
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProfileAssignment = (pKey: "scifi" | "erotica" | "clara" | "renee") => {
|
||||
const current = legalForm.assignedProfiles || [];
|
||||
if (current.includes(pKey)) {
|
||||
setLegalForm({ ...legalForm, assignedProfiles: current.filter(k => k !== pKey) });
|
||||
} else {
|
||||
setLegalForm({ ...legalForm, assignedProfiles: [...current, pKey] });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignAllProfiles = (assignAll: boolean) => {
|
||||
if (assignAll) {
|
||||
setLegalForm({ ...legalForm, assignedProfiles: ["scifi", "erotica", "clara", "renee"] });
|
||||
} else {
|
||||
setLegalForm({ ...legalForm, assignedProfiles: [] });
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
|
||||
|
|
@ -523,6 +644,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
<BookOpen className="w-4 h-4" />
|
||||
<span>Bücherregal ({profile.books.length})</span>
|
||||
</button>
|
||||
<button
|
||||
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"}`}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
<span>Rechtliches & Impressum ({(authorData.legalDocuments || []).length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1616,6 +1744,466 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB: LEGAL DOCUMENTS (IMPRESSUM & DATENSCHUTZ) */}
|
||||
{activeTab === "legal" && (
|
||||
<div className="p-6 md:p-8 space-y-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-slate-800 pb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Scale className="w-5 h-5 text-indigo-400" />
|
||||
<span>Rechtliche Pflichtangaben & Dokumente</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Erstellen und verwalten Sie Impressum, Datenschutzerklärungen und individuelle Rechtsbelehrungen für Ihre Autorenprofile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!editingLegalId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingLegalId("new");
|
||||
setLegalForm({
|
||||
title: "Impressum",
|
||||
type: "impressum",
|
||||
updatedAt: new Date().toISOString().split("T")[0],
|
||||
assignedProfiles: ["scifi", "erotica", "clara", "renee"],
|
||||
content: ""
|
||||
});
|
||||
setLegalPreviewMode("edit");
|
||||
}}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 cursor-pointer shadow-lg shadow-indigo-950/50"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Neues Dokument anlegen</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document List when not editing */}
|
||||
{!editingLegalId ? (
|
||||
<div className="space-y-6">
|
||||
{(!authorData.legalDocuments || authorData.legalDocuments.length === 0) ? (
|
||||
<div className="p-12 text-center bg-slate-900/40 border border-slate-800 border-dashed rounded-2xl space-y-4">
|
||||
<Scale className="w-10 h-10 text-slate-600 mx-auto" />
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-white">Noch keine rechtlichen Dokumente angelegt</h4>
|
||||
<p className="text-xs text-slate-400 max-w-md mx-auto mt-1">
|
||||
Legen Sie ein Impressum oder eine Datenschutzerklärung an, um rechtliche Transparenz auf Ihren Portfolioseiten zu gewährleisten.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingLegalId("new");
|
||||
setLegalForm({
|
||||
title: "Impressum",
|
||||
type: "impressum",
|
||||
updatedAt: new Date().toISOString().split("T")[0],
|
||||
assignedProfiles: ["scifi", "erotica", "clara", "renee"],
|
||||
content: ""
|
||||
});
|
||||
}}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold transition-all inline-flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Erstes Dokument anlegen</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{authorData.legalDocuments.map((doc) => {
|
||||
const profileNamesMap = {
|
||||
scifi: authorData.scifi?.name || "Daniel Hesse",
|
||||
erotica: authorData.erotica?.name || "Annie Slone",
|
||||
clara: authorData.clara?.name || "Clara Finch",
|
||||
renee: authorData.renee?.name || "Renee Heart"
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="p-5 bg-slate-900 border border-slate-800 hover:border-slate-700 rounded-2xl transition-all space-y-4 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-slate-950 border border-slate-800 rounded-xl text-indigo-400">
|
||||
{doc.type === "impressum" ? (
|
||||
<Scale className="w-5 h-5" />
|
||||
) : doc.type === "privacy" ? (
|
||||
<ShieldCheck className="w-5 h-5 text-emerald-400" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5 text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-base font-bold text-white">{doc.title}</h4>
|
||||
<span className="px-2 py-0.5 bg-slate-800 border border-slate-700 text-slate-300 text-[10px] font-mono rounded-md">
|
||||
{doc.type === "impressum" ? "Impressum (§ 5 DDG)" : doc.type === "privacy" ? "Datenschutz (DSGVO)" : "Individuell"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
Stand: {doc.updatedAt || "Aktuell"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleDuplicateLegalDocument(doc)}
|
||||
title="Dokument duplizieren"
|
||||
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white rounded-lg text-xs font-semibold transition-colors cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Kopieren</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingLegalId(doc.id);
|
||||
setLegalForm({ ...doc });
|
||||
setLegalPreviewMode("edit");
|
||||
}}
|
||||
className="px-3 py-2 bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 hover:text-indigo-200 border border-indigo-500/30 rounded-lg text-xs font-semibold transition-colors cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
<span>Bearbeiten</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteLegalDocument(doc.id)}
|
||||
title="Dokument löschen"
|
||||
className="p-2 bg-rose-950/40 hover:bg-rose-900/60 text-rose-400 hover:text-rose-200 border border-rose-900/50 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assigned Profiles Badges */}
|
||||
<div className="pt-2 border-t border-slate-800/60 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-[11px] font-mono text-slate-500 mr-1">Freigegeben für:</span>
|
||||
{(["scifi", "erotica", "clara", "renee"] as const).map((pKey) => {
|
||||
const isAssigned = doc.assignedProfiles?.includes(pKey);
|
||||
return (
|
||||
<span
|
||||
key={pKey}
|
||||
className={`px-2.5 py-0.5 rounded-full text-[11px] font-medium border ${
|
||||
isAssigned
|
||||
? "bg-indigo-950/60 border-indigo-700/60 text-indigo-300"
|
||||
: "bg-slate-950/40 border-slate-800/60 text-slate-600 line-through"
|
||||
}`}
|
||||
>
|
||||
{profileNamesMap[pKey]}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Short preview of content */}
|
||||
<div className="p-3 bg-slate-950/60 rounded-xl border border-slate-800/40 text-xs text-slate-400 line-clamp-2 font-mono">
|
||||
{doc.content ? doc.content.slice(0, 200) + (doc.content.length > 200 ? "..." : "") : "Kein Textinhalt hinterlegt."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Document Editor Form */
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 md:p-8 space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-5 h-5 text-indigo-400" />
|
||||
<h4 className="text-lg font-bold text-white">
|
||||
{editingLegalId === "new" ? "Neues Rechtsdokument anlegen" : `Dokument bearbeiten: ${legalForm.title || ""}`}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-slate-950 p-1 rounded-xl border border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLegalPreviewMode("edit")}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
legalPreviewMode === "edit"
|
||||
? "bg-indigo-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
<span>Bearbeiten</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLegalPreviewMode("preview")}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
legalPreviewMode === "preview"
|
||||
? "bg-indigo-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Live-Vorschau</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form fields */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1 sm:col-span-1">
|
||||
<label className="text-xs font-mono text-slate-400 uppercase">Dokument-Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={legalForm.title || ""}
|
||||
onChange={(e) => setLegalForm({ ...legalForm, title: e.target.value })}
|
||||
placeholder="z.B. Impressum"
|
||||
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:col-span-1">
|
||||
<label className="text-xs font-mono text-slate-400 uppercase">Dokumenttyp</label>
|
||||
<select
|
||||
value={legalForm.type || "impressum"}
|
||||
onChange={(e) => setLegalForm({ ...legalForm, type: e.target.value as any })}
|
||||
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm"
|
||||
>
|
||||
<option value="impressum">Impressum (§ 5 DDG)</option>
|
||||
<option value="privacy">Datenschutzerklärung (DSGVO)</option>
|
||||
<option value="custom">Individuelles Rechtsdokument</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:col-span-1">
|
||||
<label className="text-xs font-mono text-slate-400 uppercase">Stand / Aktualisierungsdatum</label>
|
||||
<input
|
||||
type="text"
|
||||
value={legalForm.updatedAt || ""}
|
||||
onChange={(e) => setLegalForm({ ...legalForm, updatedAt: e.target.value })}
|
||||
placeholder="z.B. 2026-08-14"
|
||||
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Assignment Selection */}
|
||||
<div className="p-4 bg-slate-950/70 border border-slate-800 rounded-xl space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h5 className="text-xs font-mono text-indigo-400 uppercase tracking-wider">
|
||||
Freigabe für Autorenprofile & Seiten
|
||||
</h5>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Wählen Sie aus, auf welchen Portfolioseiten dieses Dokument im Footer als Link eingeblendet wird.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAssignAllProfiles(true)}
|
||||
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white rounded-lg text-xs font-mono transition-colors cursor-pointer"
|
||||
>
|
||||
Alle auswählen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAssignAllProfiles(false)}
|
||||
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white rounded-lg text-xs font-mono transition-colors cursor-pointer"
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-2">
|
||||
{[
|
||||
{ key: "scifi" as const, name: authorData.scifi?.name || "Daniel Hesse", badge: "Sci-Fi" },
|
||||
{ key: "erotica" as const, name: authorData.erotica?.name || "Annie Slone", badge: "Erotik" },
|
||||
{ key: "clara" as const, name: authorData.clara?.name || "Clara Finch", badge: "Naturlyrik" },
|
||||
{ key: "renee" as const, name: authorData.renee?.name || "Renee Heart", badge: "Romantik" }
|
||||
].map((item) => {
|
||||
const checked = legalForm.assignedProfiles?.includes(item.key);
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => toggleProfileAssignment(item.key)}
|
||||
className={`p-3 rounded-xl border transition-all cursor-pointer flex items-center justify-between gap-2 select-none ${
|
||||
checked
|
||||
? "bg-indigo-950/40 border-indigo-500/60 text-white"
|
||||
: "bg-slate-900/60 border-slate-800 text-slate-400 hover:border-slate-700"
|
||||
}`}
|
||||
>
|
||||
<div className="truncate">
|
||||
<span className="text-[10px] font-mono text-indigo-400 block">{item.badge}</span>
|
||||
<span className="text-xs font-bold truncate block">{item.name}</span>
|
||||
</div>
|
||||
{checked ? (
|
||||
<CheckSquare className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-slate-600 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Editor or Preview */}
|
||||
{legalPreviewMode === "edit" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<label className="text-xs font-mono text-slate-400 uppercase flex items-center gap-1.5">
|
||||
<span>Dokument-Inhalt (Markdown unterstützt)</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<span className="text-slate-500">Mustertexte einfügen:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (legalForm.content && !confirm("Aktuellen Inhalt durch das DDG-Impressum-Muster ersetzen?")) return;
|
||||
setLegalForm({
|
||||
...legalForm,
|
||||
title: "Impressum",
|
||||
type: "impressum",
|
||||
content: `### Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG)
|
||||
|
||||
**Daniel Hesse**
|
||||
Musterstraße 12
|
||||
10115 Berlin
|
||||
Deutschland
|
||||
|
||||
### Kontakt
|
||||
**E-Mail:** kontakt@danielhesse.de
|
||||
**Website:** https://danielhesse.de
|
||||
|
||||
### Redaktionell verantwortlich
|
||||
Daniel Hesse
|
||||
Musterstraße 12
|
||||
10115 Berlin
|
||||
|
||||
### Künstlerische Pseudonyme & Urheberrecht
|
||||
Die auf dieser Internetpräsenz präsentierten literarischen Werke und Inhalte unter den Autorennamen **Daniel Hesse**, **Annie Slone**, **Clara Finch** und **Renee Heart** sind urheberrechtlich geschützte künstlerische Veröffentlichungen.
|
||||
|
||||
### EU-Streitschlichtung
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/
|
||||
Unsere E-Mail-Adresse finden Sie oben im Impressum.
|
||||
|
||||
### Verbraucherstreitbeilegung / Universalschlichtungsstelle
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.`
|
||||
});
|
||||
}}
|
||||
className="px-2 py-0.5 bg-slate-800 hover:bg-slate-700 text-indigo-300 rounded font-mono cursor-pointer"
|
||||
>
|
||||
+ Impressum-Muster
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (legalForm.content && !confirm("Aktuellen Inhalt durch das DSGVO-Datenschutz-Muster ersetzen?")) return;
|
||||
setLegalForm({
|
||||
...legalForm,
|
||||
title: "Datenschutzerklärung",
|
||||
type: "privacy",
|
||||
content: `### 1. Datenschutz auf einen Blick
|
||||
|
||||
**Allgemeine Hinweise**
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.
|
||||
|
||||
### 2. Verantwortliche Stelle
|
||||
Verantwortlich für die Datenverarbeitung auf dieser Website ist:
|
||||
|
||||
**Daniel Hesse**
|
||||
Musterstraße 12
|
||||
10115 Berlin
|
||||
E-Mail: kontakt@danielhesse.de
|
||||
|
||||
### 3. Datenerfassung auf dieser Website
|
||||
|
||||
**Bereitstellung der Website und Erstellung der Logfiles**
|
||||
Beim Aufrufen unserer Website werden durch den auf Ihrem Endgerät zum Einsatz kommenden Browser automatisch Informationen an den Server unserer Website gesendet. Diese Informationen werden temporär in einem sogenannten Logfile gespeichert:
|
||||
- Browsertyp und Browserversion
|
||||
- Verwendetes Betriebssystem
|
||||
- Referrer URL (die zuvor besuchte Seite)
|
||||
- Hostname des zugreifenden Rechners
|
||||
- Uhrzeit der Serveranfrage
|
||||
- IP-Adresse
|
||||
|
||||
Die Rechtsgrundlage für die Datenverarbeitung ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse zur Gewährleistung eines reibungslosen Verbindungsaufbaus und der Systemsicherheit).
|
||||
|
||||
**Kontaktaufnahme per E-Mail**
|
||||
Wenn Sie uns per E-Mail Anfragen zukommen lassen, werden Ihre Angaben inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter (Art. 6 Abs. 1 lit. b bzw. lit. f DSGVO).
|
||||
|
||||
### 4. Eingebundene Dienste Dritter
|
||||
|
||||
**Spotify Player & Externe Buch-Links**
|
||||
Auf einigen Seiten sind Hörbeispiele bzw. Playlists des Dienstes Spotify eingebunden oder Links zu Buchhändlern (z. B. Amazon, Thalia) gesetzt. Wenn Sie diese externen Links anklicken oder mit externen Playern interagieren, werden Daten an die jeweiligen Anbieter übertragen.
|
||||
|
||||
### 5. Ihre Rechte als betroffene Person
|
||||
Sie haben im Rahmen der geltenden gesetzlichen Bestimmungen jederzeit das Recht auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, deren Herkunft und Empfänger und den Zweck der Datenverarbeitung und ggf. ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten.`
|
||||
});
|
||||
}}
|
||||
className="px-2 py-0.5 bg-slate-800 hover:bg-slate-700 text-emerald-300 rounded font-mono cursor-pointer"
|
||||
>
|
||||
+ Datenschutz-Muster
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={14}
|
||||
value={legalForm.content || ""}
|
||||
onChange={(e) => setLegalForm({ ...legalForm, content: e.target.value })}
|
||||
placeholder="Tragen Sie hier Ihren Gesetzestext oder Ihre Datenschutzerklärung ein..."
|
||||
className="w-full px-4 py-3 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-xs font-mono leading-relaxed"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Formatierungstipp: Überschriften mit <code className="text-slate-300">### Titel</code>, Fettgedruckt mit <code className="text-slate-300">**Text**</code>, Listen mit <code className="text-slate-300">- Punkt</code>, Links mit <code className="text-slate-300">[Titel](URL)</code>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Live Preview Box */
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-mono text-slate-400 uppercase">Live-Vorschau der Darstellung:</label>
|
||||
<div className="p-6 bg-slate-950 border border-slate-800 rounded-xl max-h-96 overflow-y-auto markdown-body text-slate-300 text-xs leading-relaxed space-y-3">
|
||||
{legalForm.content ? (
|
||||
<Markdown>{legalForm.content}</Markdown>
|
||||
) : (
|
||||
<p className="text-slate-500 italic">Noch kein Text zum Anzeigen vorhanden.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingLegalId(null);
|
||||
setLegalForm({});
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white rounded-xl text-xs font-semibold transition-colors cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveLegalDocument}
|
||||
disabled={loading || !legalForm.title?.trim()}
|
||||
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 cursor-pointer disabled:opacity-50 shadow-lg shadow-indigo-950/50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>{loading ? "Speichern..." : "Dokument speichern"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { Book, Project, AuthorProfile } from "../types";
|
||||
import { Book, Project, AuthorProfile, LegalDocument } from "../types";
|
||||
import { Leaf, Feather, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
|
||||
import SpotlightSection from "./SpotlightSection";
|
||||
import LegalModal from "./LegalModal";
|
||||
|
||||
interface ClaraPortfolioProps {
|
||||
data: AuthorProfile;
|
||||
legalDocuments?: LegalDocument[];
|
||||
}
|
||||
|
||||
export default function ClaraPortfolio({ data }: ClaraPortfolioProps) {
|
||||
export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortfolioProps) {
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
||||
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
||||
|
||||
const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("clara"));
|
||||
|
||||
const accentColor = data.accentColor || "#8FA797";
|
||||
const secondaryColor = data.secondaryColor || "#B59EB1";
|
||||
|
|
@ -458,11 +464,39 @@ export default function ClaraPortfolio({ data }: ClaraPortfolioProps) {
|
|||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-stone-800 py-12 px-4 text-center text-xs text-stone-500 italic" style={{ backgroundColor: bgColor }}>
|
||||
<div className="max-w-7xl mx-auto space-y-2">
|
||||
<div className="max-w-7xl mx-auto space-y-3">
|
||||
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-stone-500">{data.footerText || "Betrachtungen, verwoben mit dem Rhythmus der wachsenden Natur."}</p>
|
||||
|
||||
{relevantLegalDocs.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 pt-3 border-t border-stone-800/60 max-w-md mx-auto not-italic">
|
||||
{relevantLegalDocs.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => {
|
||||
setActiveLegalId(doc.id);
|
||||
setLegalModalOpen(true);
|
||||
}}
|
||||
className="text-stone-400 hover:text-[#8FA797] transition-colors underline underline-offset-4 cursor-pointer"
|
||||
>
|
||||
{doc.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Legal Modal */}
|
||||
<LegalModal
|
||||
isOpen={legalModalOpen}
|
||||
onClose={() => setLegalModalOpen(false)}
|
||||
documents={relevantLegalDocs}
|
||||
initialDocId={activeLegalId}
|
||||
accentColor={accentColor}
|
||||
secondaryColor={secondaryColor}
|
||||
fontFamily={data.fontFamily || "serif"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { Book, Project, AuthorProfile } from "../types";
|
||||
import { Book, Project, AuthorProfile, LegalDocument } from "../types";
|
||||
import { Heart, Key, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
|
||||
import SpotlightSection from "./SpotlightSection";
|
||||
import LegalModal from "./LegalModal";
|
||||
|
||||
interface EroticaPortfolioProps {
|
||||
data: AuthorProfile;
|
||||
legalDocuments?: LegalDocument[];
|
||||
}
|
||||
|
||||
export default function EroticaPortfolio({ data }: EroticaPortfolioProps) {
|
||||
export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaPortfolioProps) {
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
||||
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
||||
|
||||
const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("erotica"));
|
||||
|
||||
const accentColor = data.accentColor || "#f97316";
|
||||
const secondaryColor = data.secondaryColor || "#f59e0b";
|
||||
|
|
@ -458,11 +464,39 @@ export default function EroticaPortfolio({ data }: EroticaPortfolioProps) {
|
|||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-neutral-800 py-12 px-4 text-center text-xs text-neutral-500 italic" style={{ backgroundColor: bgColor }}>
|
||||
<div className="max-w-7xl mx-auto space-y-2">
|
||||
<div className="max-w-7xl mx-auto space-y-3">
|
||||
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-neutral-500">{data.footerText || "Geschlossenes Refugium für feine, hochästhetische Phantasiereisen."}</p>
|
||||
|
||||
{relevantLegalDocs.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 pt-3 border-t border-neutral-800/60 max-w-md mx-auto not-italic">
|
||||
{relevantLegalDocs.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => {
|
||||
setActiveLegalId(doc.id);
|
||||
setLegalModalOpen(true);
|
||||
}}
|
||||
className="text-neutral-400 hover:text-orange-400 transition-colors underline underline-offset-4 cursor-pointer"
|
||||
>
|
||||
{doc.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Legal Modal */}
|
||||
<LegalModal
|
||||
isOpen={legalModalOpen}
|
||||
onClose={() => setLegalModalOpen(false)}
|
||||
documents={relevantLegalDocs}
|
||||
initialDocId={activeLegalId}
|
||||
accentColor={accentColor}
|
||||
secondaryColor={secondaryColor}
|
||||
fontFamily={data.fontFamily || "serif"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
199
src/components/LegalModal.tsx
Normal file
199
src/components/LegalModal.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { LegalDocument } from "../types";
|
||||
import { X, ShieldCheck, FileText, Scale, Printer } from "lucide-react";
|
||||
|
||||
interface LegalModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
documents: LegalDocument[];
|
||||
initialDocId?: string | null;
|
||||
accentColor?: string;
|
||||
secondaryColor?: string;
|
||||
fontFamily?: "sans" | "serif" | "mono";
|
||||
}
|
||||
|
||||
export default function LegalModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
documents,
|
||||
initialDocId,
|
||||
accentColor = "#38bdf8",
|
||||
secondaryColor = "#818cf8",
|
||||
fontFamily = "sans"
|
||||
}: LegalModalProps) {
|
||||
const [selectedDocId, setSelectedDocId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialDocId && documents.some(d => d.id === initialDocId)) {
|
||||
setSelectedDocId(initialDocId);
|
||||
} else if (documents.length > 0) {
|
||||
setSelectedDocId(documents[0].id);
|
||||
}
|
||||
}, [initialDocId, documents, isOpen]);
|
||||
|
||||
// Handle ESC key to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || documents.length === 0) return null;
|
||||
|
||||
const currentDoc = documents.find(d => d.id === selectedDocId) || documents[0];
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const getDocIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "impressum":
|
||||
return <Scale className="w-4 h-4" />;
|
||||
case "privacy":
|
||||
return <ShieldCheck className="w-4 h-4" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-950/85 backdrop-blur-md transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-full items-center justify-center p-3 sm:p-4 text-left">
|
||||
<div className={`relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all w-full max-w-3xl my-6 p-6 sm:p-8 space-y-6 font-${fontFamily}`}>
|
||||
|
||||
{/* Header Bar */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<span style={{ color: accentColor }}>
|
||||
{getDocIcon(currentDoc?.type || "custom")}
|
||||
</span>
|
||||
<h2 className="text-lg sm:text-xl font-bold tracking-tight">
|
||||
{currentDoc?.title || "Rechtliche Hinweise"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
title="Dokument drucken"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 border border-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-2.5 py-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg text-xs font-mono border border-slate-800 transition-colors cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<span>[ESC]</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Tabs Switcher if more than 1 document */}
|
||||
{documents.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5 p-1 bg-slate-950/80 rounded-xl border border-slate-800">
|
||||
{documents.map((doc) => {
|
||||
const isActive = doc.id === currentDoc.id;
|
||||
return (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => setSelectedDocId(doc.id)}
|
||||
className={`px-3.5 py-1.5 rounded-lg text-xs font-medium transition-all flex items-center gap-2 cursor-pointer ${
|
||||
isActive
|
||||
? "bg-slate-800 text-white shadow-sm font-semibold"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-slate-900"
|
||||
}`}
|
||||
style={isActive ? { borderLeft: `3px solid ${accentColor}` } : {}}
|
||||
>
|
||||
{getDocIcon(doc.type)}
|
||||
<span>{doc.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Content View */}
|
||||
<div className="bg-slate-950/50 rounded-xl p-5 sm:p-7 border border-slate-800/80 max-h-[60vh] overflow-y-auto leading-relaxed text-slate-300 text-sm markdown-body space-y-4">
|
||||
{currentDoc?.content ? (
|
||||
<Markdown
|
||||
components={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-white mb-4 pb-2 border-b border-slate-800" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="text-lg sm:text-xl font-bold text-white mt-6 mb-3" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="text-base font-semibold text-slate-100 mt-5 mb-2" {...props} />
|
||||
),
|
||||
p: ({ node, ...props }) => (
|
||||
<p className="mb-3 text-slate-300 text-sm leading-relaxed" {...props} />
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="list-disc pl-5 mb-4 space-y-1.5 text-slate-300 text-sm" {...props} />
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="list-decimal pl-5 mb-4 space-y-1.5 text-slate-300 text-sm" {...props} />
|
||||
),
|
||||
li: ({ node, ...props }) => (
|
||||
<li className="leading-relaxed" {...props} />
|
||||
),
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: accentColor }}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ node, ...props }) => (
|
||||
<strong className="font-semibold text-white" {...props} />
|
||||
),
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote className="border-l-4 pl-4 py-1 italic text-slate-400 border-slate-700 bg-slate-900/40 rounded-r my-3" {...props} />
|
||||
)
|
||||
}}
|
||||
>
|
||||
{currentDoc.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<p className="text-slate-500 italic">Kein Inhalt hinterlegt.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer inside modal */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 font-mono pt-2 border-t border-slate-800/80">
|
||||
{currentDoc?.updatedAt ? (
|
||||
<span>Stand: {currentDoc.updatedAt}</span>
|
||||
) : (
|
||||
<span>Rechtliche Information</span>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-white rounded-lg text-xs font-semibold transition-all cursor-pointer"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,24 @@
|
|||
import React, { useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { AuthorProfile, Book, Project } from "../types";
|
||||
import { AuthorProfile, Book, Project, LegalDocument } from "../types";
|
||||
import {
|
||||
Heart, Sparkles, BookOpen, ExternalLink, X, ArrowRight, Sun, Music
|
||||
} from "lucide-react";
|
||||
import SpotlightSection from "./SpotlightSection";
|
||||
import LegalModal from "./LegalModal";
|
||||
|
||||
interface ReneePortfolioProps {
|
||||
data: AuthorProfile;
|
||||
legalDocuments?: LegalDocument[];
|
||||
}
|
||||
|
||||
export default function ReneePortfolio({ data }: ReneePortfolioProps) {
|
||||
export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortfolioProps) {
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
||||
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
||||
|
||||
const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("renee"));
|
||||
|
||||
const accentColor = data.accentColor || "#eab308";
|
||||
const secondaryColor = data.secondaryColor || "#ec4899";
|
||||
|
|
@ -454,11 +460,39 @@ export default function ReneePortfolio({ data }: ReneePortfolioProps) {
|
|||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-800 py-12 px-4 text-center text-xs text-slate-500 font-sans" style={{ backgroundColor: bgColor }}>
|
||||
<div className="max-w-7xl mx-auto space-y-2">
|
||||
<div className="max-w-7xl mx-auto space-y-3">
|
||||
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-slate-500">{data.footerText || "Geschrieben mit viel Gefühl und Herzblut."}</p>
|
||||
|
||||
{relevantLegalDocs.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 pt-3 border-t border-slate-800/60 max-w-md mx-auto">
|
||||
{relevantLegalDocs.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => {
|
||||
setActiveLegalId(doc.id);
|
||||
setLegalModalOpen(true);
|
||||
}}
|
||||
className="text-slate-400 hover:text-yellow-400 transition-colors underline underline-offset-4 cursor-pointer"
|
||||
>
|
||||
{doc.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Legal Modal */}
|
||||
<LegalModal
|
||||
isOpen={legalModalOpen}
|
||||
onClose={() => setLegalModalOpen(false)}
|
||||
documents={relevantLegalDocs}
|
||||
initialDocId={activeLegalId}
|
||||
accentColor={accentColor}
|
||||
secondaryColor={secondaryColor}
|
||||
fontFamily={data.fontFamily || "sans"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { Book, Project, AuthorProfile } from "../types";
|
||||
import { Book, Project, AuthorProfile, LegalDocument } from "../types";
|
||||
import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, Moon } from "lucide-react";
|
||||
import SpotlightSection from "./SpotlightSection";
|
||||
import LegalModal from "./LegalModal";
|
||||
|
||||
interface SciFiPortfolioProps {
|
||||
data: AuthorProfile;
|
||||
legalDocuments?: LegalDocument[];
|
||||
}
|
||||
|
||||
export default function SciFiPortfolio({ data }: SciFiPortfolioProps) {
|
||||
export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortfolioProps) {
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [legalModalOpen, setLegalModalOpen] = useState(false);
|
||||
const [activeLegalId, setActiveLegalId] = useState<string | null>(null);
|
||||
|
||||
const relevantLegalDocs = legalDocuments.filter(doc => doc.assignedProfiles.includes("scifi"));
|
||||
|
||||
const accentColor = data.accentColor || "#06b6d4";
|
||||
const secondaryColor = data.secondaryColor || "#a855f7";
|
||||
|
|
@ -486,8 +492,36 @@ export default function SciFiPortfolio({ data }: SciFiPortfolioProps) {
|
|||
<div className="max-w-7xl mx-auto space-y-4">
|
||||
<p>© {new Date().getFullYear()} {data.name}. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-slate-500">{data.footerText || "Dokumentation, Fragmente und stellare Chiffren. Verschlüsselt mit moderner Node.JS Technologie."}</p>
|
||||
|
||||
{relevantLegalDocs.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 pt-3 border-t border-slate-800/60 max-w-md mx-auto">
|
||||
{relevantLegalDocs.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => {
|
||||
setActiveLegalId(doc.id);
|
||||
setLegalModalOpen(true);
|
||||
}}
|
||||
className="text-slate-400 hover:text-cyan-400 transition-colors underline underline-offset-4 cursor-pointer"
|
||||
>
|
||||
{doc.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Legal Modal */}
|
||||
<LegalModal
|
||||
isOpen={legalModalOpen}
|
||||
onClose={() => setLegalModalOpen(false)}
|
||||
documents={relevantLegalDocs}
|
||||
initialDocId={activeLegalId}
|
||||
accentColor={accentColor}
|
||||
secondaryColor={secondaryColor}
|
||||
fontFamily={data.fontFamily || "sans"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -302,5 +302,82 @@ export const defaultAuthorData: AuthorData = {
|
|||
cardBgColor: "#1e293b",
|
||||
fontFamily: "sans",
|
||||
heroBannerUrl: ""
|
||||
},
|
||||
legalDocuments: [
|
||||
{
|
||||
id: "legal_impressum",
|
||||
title: "Impressum",
|
||||
type: "impressum",
|
||||
updatedAt: "2026-08-14",
|
||||
assignedProfiles: ["scifi", "erotica", "clara", "renee"],
|
||||
content: `### Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG)
|
||||
|
||||
**Daniel Hesse**
|
||||
Musterstraße 12
|
||||
10115 Berlin
|
||||
Deutschland
|
||||
|
||||
### Kontakt
|
||||
**E-Mail:** kontakt@danielhesse.de
|
||||
**Website:** https://danielhesse.de
|
||||
|
||||
### Redaktionell verantwortlich
|
||||
Daniel Hesse
|
||||
Musterstraße 12
|
||||
10115 Berlin
|
||||
|
||||
### Künstlerische Pseudonyme & Urheberrecht
|
||||
Die auf dieser Internetpräsenz präsentierten literarischen Werke und Inhalte unter den Autorennamen **Daniel Hesse**, **Annie Slone**, **Clara Finch** und **Renee Heart** sind urheberrechtlich geschützte künstlerische Veröffentlichungen.
|
||||
|
||||
### EU-Streitschlichtung
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/
|
||||
Unsere E-Mail-Adresse finden Sie oben im Impressum.
|
||||
|
||||
### Verbraucherstreitbeilegung / Universalschlichtungsstelle
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.`
|
||||
},
|
||||
{
|
||||
id: "legal_privacy",
|
||||
title: "Datenschutzerklärung",
|
||||
type: "privacy",
|
||||
updatedAt: "2026-08-14",
|
||||
assignedProfiles: ["scifi", "erotica", "clara", "renee"],
|
||||
content: `### 1. Datenschutz auf einen Blick
|
||||
|
||||
**Allgemeine Hinweise**
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.
|
||||
|
||||
### 2. Verantwortliche Stelle
|
||||
Verantwortlich für die Datenverarbeitung auf dieser Website ist:
|
||||
|
||||
**Daniel Hesse**
|
||||
Musterstraße 12
|
||||
10115 Berlin
|
||||
E-Mail: kontakt@danielhesse.de
|
||||
|
||||
### 3. Datenerfassung auf dieser Website
|
||||
|
||||
**Bereitstellung der Website und Erstellung der Logfiles**
|
||||
Beim Aufrufen unserer Website werden durch den auf Ihrem Endgerät zum Einsatz kommenden Browser automatisch Informationen an den Server unserer Website gesendet. Diese Informationen werden temporär in einem sogenannten Logfile gespeichert:
|
||||
- Browsertyp und Browserversion
|
||||
- Verwendetes Betriebssystem
|
||||
- Referrer URL (die zuvor besuchte Seite)
|
||||
- Hostname des zugreifenden Rechners
|
||||
- Uhrzeit der Serveranfrage
|
||||
- IP-Adresse
|
||||
|
||||
Die Rechtsgrundlage für die Datenverarbeitung ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse zur Gewährleistung eines reibungslosen Verbindungsaufbaus und der Systemsicherheit).
|
||||
|
||||
**Kontaktaufnahme per E-Mail**
|
||||
Wenn Sie uns per E-Mail Anfragen zukommen lassen, werden Ihre Angaben inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter (Art. 6 Abs. 1 lit. b bzw. lit. f DSGVO).
|
||||
|
||||
### 4. Eingebundene Dienste Dritter
|
||||
|
||||
**Spotify Player & Externe Buch-Links**
|
||||
Auf einigen Seiten sind Hörbeispiele bzw. Playlists des Dienstes Spotify eingebunden oder Links zu Buchhändlern (z. B. Amazon, Thalia) gesetzt. Wenn Sie diese externen Links anklicken oder mit externen Playern interagieren, werden Daten an die jeweiligen Anbieter übertragen.
|
||||
|
||||
### 5. Ihre Rechte als betroffene Person
|
||||
Sie haben im Rahmen der geltenden gesetzlichen Bestimmungen jederzeit das Recht auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, deren Herkunft und Empfänger und den Zweck der Datenverarbeitung und ggf. ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten.`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
|
|||
10
src/types.ts
10
src/types.ts
|
|
@ -67,11 +67,21 @@ export interface AuthorProfile {
|
|||
heroBannerUrl?: string;
|
||||
}
|
||||
|
||||
export interface LegalDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
type: "impressum" | "privacy" | "custom";
|
||||
content: string; // Markdown formatted text
|
||||
updatedAt?: string;
|
||||
assignedProfiles: ("scifi" | "erotica" | "clara" | "renee")[];
|
||||
}
|
||||
|
||||
export interface AuthorData {
|
||||
scifi: AuthorProfile;
|
||||
erotica: AuthorProfile;
|
||||
clara: AuthorProfile;
|
||||
renee: AuthorProfile;
|
||||
legalDocuments?: LegalDocument[];
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
|
|
|
|||
Loading…
Reference in a new issue