Merge pull request #13 from Dada1981/codex/persistence-seo-hardening

feat: require consent for Spotify embeds
This commit is contained in:
Dada1981 2026-08-15 00:34:38 +02:00 committed by GitHub
commit cf3acf0906
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 597 additions and 599 deletions

View file

@ -15,7 +15,22 @@ const PORT = 3000;
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
const DATA_FILE = path.join(DATA_DIR, "database.json");
const BACKUP_DIR = path.join(DATA_DIR, "backups");
const CURRENT_SCHEMA_VERSION = 1;
const CURRENT_SCHEMA_VERSION = 2;
const LEGACY_DEFAULT_IMAGE_URLS = new Set([
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300",
"https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=300",
"https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=300",
"https://images.unsplash.com/photo-1501504905252-473c47e087f8?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1448375240586-882707db888b?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=300",
"https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1543002588-bfa74002ed7e?auto=format&fit=crop&q=80&w=400",
]);
let writeQueue: Promise<void> = Promise.resolve();
let serverReady = false;
@ -83,6 +98,7 @@ async function initDatabase(): Promise<AuthorData> {
}
// Legacy files may not contain all newer profiles yet; migrations below add them.
const parsed = rawParsed as AuthorData;
const loadedSchemaVersion = parsed.schemaVersion ?? 0;
let modified = false;
// Ensure all dynamic static text and design properties exist for all profiles
@ -129,7 +145,20 @@ async function initDatabase(): Promise<AuthorData> {
modified = true;
}
if (parsed.schemaVersion === undefined) {
// Version 2 removes only the exact historical demo images. User-uploaded
// and other explicitly configured image URLs remain untouched.
if (loadedSchemaVersion < 2) {
for (const key of profileKeys) {
const profile = parsed[key];
if (LEGACY_DEFAULT_IMAGE_URLS.has(profile.avatarUrl)) profile.avatarUrl = "";
for (const book of profile.books) {
if (LEGACY_DEFAULT_IMAGE_URLS.has(book.coverUrl)) book.coverUrl = "";
}
}
modified = true;
}
if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION) {
parsed.schemaVersion = CURRENT_SCHEMA_VERSION;
modified = true;
}

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,11 @@
import { useState } from "react";
import Markdown from "react-markdown";
import { Book, Project, AuthorProfile, LegalDocument } from "../types";
import { Leaf, Feather, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import { Leaf, Feather, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
interface ClaraPortfolioProps {
data: AuthorProfile;
@ -27,7 +28,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
const fontClass = data.fontFamily === "sans" ? "font-sans" : data.fontFamily === "mono" ? "font-mono" : "font-serif";
return (
<div
<div
className={`min-h-screen text-stone-200 ${fontClass} selection:bg-[#B39EAF]/30 selection:text-white overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
@ -47,14 +48,14 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
{/* Header / Hero */}
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-stone-800/80">
<div className="text-center max-w-4xl mx-auto space-y-6">
<div
<div
className="inline-flex items-center gap-2 px-4 py-1.5 bg-stone-900/80 border rounded-full text-xs tracking-wider mb-2 shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Leaf className="w-3.5 h-3.5 animate-pulse" style={{ color: accentColor }} />
<span className="font-serif italic text-stone-300">{data.badgeText || "Lyrik & Naturphilosophie"}</span>
</div>
<h1 className="text-4xl md:text-6xl font-serif tracking-widest text-stone-100 uppercase font-light drop-shadow-sm">
{data.heroTitle || data.name || "CLARA FINCH"}
</h1>
@ -65,19 +66,19 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-sans">
<a
<a
href="#ueber-mich"
className="px-4 py-2 rounded-full border border-stone-800 bg-stone-900/60 hover:border-stone-500 transition-all text-stone-300 uppercase tracking-wider"
>
🌿 {data.aboutTitle || "Über mich"}
</a>
<a
<a
href="#projekte"
className="px-4 py-2 rounded-full border border-stone-800 bg-stone-900/60 hover:border-stone-500 transition-all text-stone-300 uppercase tracking-wider"
>
✍️ {data.projectsTitle || "Notizbücher"}
</a>
<a
<a
href="#buecherregal"
className="px-4 py-2 rounded-full border border-stone-800 bg-stone-900/60 hover:border-stone-500 transition-all text-stone-300 uppercase tracking-wider"
>
@ -89,7 +90,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
{/* Main Grid */}
<main className="max-w-7xl mx-auto px-4 py-16 space-y-24">
{/* About writer */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
@ -97,9 +98,9 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
{/* Outer soft natural glow */}
<div className="absolute -inset-1.5 rounded-2xl blur opacity-20 group-hover:opacity-40 transition duration-1000 group-hover:duration-200" style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }} />
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-stone-800" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || "https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=400"}
alt={data.name}
<img
src={data.avatarUrl || undefined}
alt={data.name}
className="w-full h-full object-cover grayscale brightness-95 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-1000"
referrerPolicy="no-referrer"
/>
@ -114,7 +115,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
<div className="prose prose-invert prose-stone max-w-none text-stone-300 leading-relaxed font-sans space-y-4">
<p className="whitespace-pre-wrap italic">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
@ -149,7 +150,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
@ -173,8 +174,8 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={project.id}
<button
key={project.id}
onClick={() => setSelectedProject(project)}
className="p-6 border rounded-xl transition-all duration-300 flex flex-col text-left w-full justify-between group cursor-pointer hover:scale-[1.01] focus:outline-none shadow-md"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}
@ -192,17 +193,17 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
{project.title}
</h3>
<div className="text-stone-300 text-sm italic line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-[#8FA797] hover:text-stone-200 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-[#8FA797] hover:text-stone-200 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
)
}}
>
{project.description}
@ -214,8 +215,8 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
</div>
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1 w-full bg-stone-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
@ -244,13 +245,13 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-stone-900/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || "https://images.unsplash.com/photo-1501504905252-473c47e087f8?auto=format&fit=crop&q=80&w=300"}
<img
src={book.coverUrl || undefined}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 z-0"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-stone-950 via-stone-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
<p className="text-stone-100 text-xs font-serif truncate">{book.title}</p>
<p className="text-[10px] font-sans mt-0.5 italic" style={{ color: accentColor }}>Verweilen <ArrowRight className="inline-block w-2.5 h-2.5 ml-0.5" /></p>
@ -271,16 +272,16 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
)}
{data.customSectionContent && (
<div className="text-stone-300 text-sm md:text-base leading-relaxed italic markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-[#8FA797] hover:text-stone-200 font-medium"
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-[#8FA797] hover:text-stone-200 font-medium"
{...props}
/>
)
)
}}
>
{data.customSectionContent}
@ -295,15 +296,15 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
{/* Book details modular modal */}
{selectedBook && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div
className="fixed inset-0 bg-stone-950/85 backdrop-blur-md transition-opacity"
<div
className="fixed inset-0 bg-stone-950/85 backdrop-blur-md transition-opacity"
onClick={() => setSelectedBook(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-stone-950 border border-stone-900 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
<button
<button
onClick={() => setSelectedBook(null)}
className="absolute top-4 right-4 text-stone-400 hover:text-stone-100 hover:bg-[#4A5D50]/80 px-2.5 py-1 rounded-md text-xs font-sans border border-stone-800 transition-all cursor-pointer"
>
@ -311,19 +312,19 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 pt-4">
{/* Book Cover */}
<div className="col-span-1 md:col-span-4 flex flex-col items-center">
<div className="rounded-xl overflow-hidden shadow-2xl border border-[#4A5D50]/30 max-w-[260px] aspect-[2/3] w-full">
<img
src={selectedBook.coverUrl}
alt={selectedBook.title}
<img
src={selectedBook.coverUrl || undefined}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
{selectedBook.buyLink && (
<a
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
@ -352,7 +353,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
<h3 className="text-3xl font-serif text-stone-100">
{selectedBook.title}
</h3>
{selectedBook.subtitle && (
<h4 className="text-sm font-sans italic text-[#B59EB1]">
{selectedBook.subtitle}
@ -366,31 +367,15 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
</div>
</div>
{/* Embedded Spotify player (Only if playlist is non-empty) */}
{/* Privacy-friendly Spotify player */}
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
<div className="space-y-2">
<p className="text-xs font-sans text-[#8FA797] flex items-center gap-2 italic">
<Leaf className="w-3.5 h-3.5 text-[#8FA797]" />
<span>Klanglandschaft (Begleitende Playlist)</span>
</p>
<div className="rounded-xl overflow-hidden bg-stone-900 border border-stone-800">
<iframe
src={`https://open.spotify.com/embed/playlist/${selectedBook.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allowFullScreen={false}
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
</div>
<SpotifyConsentPlayer playlist={selectedBook.spotifyPlaylistId} title={`Spotify-Playlist zu ${selectedBook.title}`} accentColor={accentColor} />
)}
</div>
</div>
</div>
</div>
</div>
@ -399,15 +384,15 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
{/* Project details modular modal */}
{selectedProject && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div
className="fixed inset-0 bg-stone-950/85 backdrop-blur-md transition-opacity"
<div
className="fixed inset-0 bg-stone-950/85 backdrop-blur-md transition-opacity"
onClick={() => setSelectedProject(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-stone-950 border border-stone-900 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-5xl p-6 md:p-8 space-y-6">
<button
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 right-4 text-stone-400 hover:text-stone-100 hover:bg-[#4A5D50]/80 px-2.5 py-1 rounded-md text-xs font-sans border border-stone-800 transition-all cursor-pointer"
>
@ -436,8 +421,8 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
<span className="font-bold" style={{ color: accentColor }}>{selectedProject.progress}%</span>
</div>
<div className="h-2 w-full bg-stone-900 rounded-full overflow-hidden border border-stone-800">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-md"
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-md"
style={{ width: `${selectedProject.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
@ -461,7 +446,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
</div>
</div>
</div>
</div>
</div>
</div>
@ -472,7 +457,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
<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) => (

View file

@ -1,10 +1,11 @@
import { useState } from "react";
import Markdown from "react-markdown";
import { Book, Project, AuthorProfile, LegalDocument } from "../types";
import { Heart, Key, Star, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import { Heart, Key, Sparkles, BookOpen, ExternalLink, ArrowRight } from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
interface EroticaPortfolioProps {
data: AuthorProfile;
@ -27,7 +28,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
const fontClass = data.fontFamily === "sans" ? "font-sans" : data.fontFamily === "mono" ? "font-mono" : "font-serif";
return (
<div
<div
className={`min-h-screen text-neutral-200 ${fontClass} selection:bg-orange-500 selection:text-black overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
@ -47,14 +48,14 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
{/* Header / Hero */}
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-neutral-800/80">
<div className="text-center max-w-4xl mx-auto space-y-6">
<div
<div
className="inline-flex items-center gap-2 px-4 py-1.5 bg-neutral-900/80 border rounded-full text-xs tracking-wider mb-2 shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Heart className="w-3.5 h-3.5 fill-current animate-pulse" style={{ color: accentColor }} />
<span>{data.badgeText || "Stilvolle Sinnlichkeit & Romantik"}</span>
</div>
<h1 className="text-4xl md:text-6xl tracking-wide text-amber-100 uppercase font-light drop-shadow-sm font-serif">
{data.heroTitle || data.name || "M. S. VELVET"}
</h1>
@ -65,19 +66,19 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-sans">
<a
<a
href="#ueber-mich"
className="px-4 py-2 rounded-full border border-neutral-800 bg-neutral-900/60 hover:border-orange-400 hover:text-orange-300 transition-all text-neutral-300 uppercase tracking-wider"
>
🍑 {data.aboutTitle || "Über mich"}
</a>
<a
<a
href="#projekte"
className="px-4 py-2 rounded-full border border-neutral-800 bg-neutral-900/60 hover:border-orange-400 hover:text-orange-300 transition-all text-neutral-300 uppercase tracking-wider"
>
✍️ {data.projectsTitle || "In der Entstehung"}
</a>
<a
<a
href="#buecherregal"
className="px-4 py-2 rounded-full border border-neutral-800 bg-neutral-900/60 hover:border-orange-400 hover:text-orange-300 transition-all text-neutral-300 uppercase tracking-wider"
>
@ -89,7 +90,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
{/* Main Grid */}
<main className="max-w-7xl mx-auto px-4 py-16 space-y-24">
{/* About writer */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
@ -97,9 +98,9 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
{/* Outer warm glow */}
<div className="absolute -inset-1.5 rounded-2xl blur opacity-25 group-hover:opacity-60 transition duration-1000 group-hover:duration-200" style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }} />
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-neutral-800" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=400"}
alt={data.name}
<img
src={data.avatarUrl || undefined}
alt={data.name}
className="w-full h-full object-cover grayscale brightness-90 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-1000"
referrerPolicy="no-referrer"
/>
@ -114,7 +115,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
<div className="prose prose-invert prose-stone max-w-none text-neutral-300 leading-relaxed font-sans space-y-4">
<p className="whitespace-pre-wrap italic">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
@ -149,7 +150,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
@ -173,8 +174,8 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={project.id}
<button
key={project.id}
onClick={() => setSelectedProject(project)}
className="p-6 border rounded-xl transition-all duration-300 flex flex-col text-left w-full justify-between group cursor-pointer hover:scale-[1.01] focus:outline-none shadow-lg"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
@ -192,17 +193,17 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
{project.title}
</h3>
<div className="text-neutral-300 text-sm italic line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-orange-400 hover:text-amber-300 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-orange-400 hover:text-amber-300 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
)
}}
>
{project.description}
@ -214,8 +215,8 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
</div>
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1 w-full bg-neutral-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
@ -244,13 +245,13 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-orange-950/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || "https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&q=80&w=300"}
<img
src={book.coverUrl || undefined}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 z-0"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-neutral-950 via-neutral-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
<p className="text-amber-100 text-xs font-serif truncate">{book.title}</p>
<p className="text-[10px] font-sans mt-0.5 italic" style={{ color: accentColor }}>Eintauchen <ArrowRight className="inline-block w-2.5 h-2.5 ml-0.5" /></p>
@ -271,16 +272,16 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
)}
{data.customSectionContent && (
<div className="text-neutral-300 text-sm md:text-base leading-relaxed italic markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-orange-400 hover:text-amber-300 font-medium"
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-orange-400 hover:text-amber-300 font-medium"
{...props}
/>
)
)
}}
>
{data.customSectionContent}
@ -295,15 +296,15 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
{/* Book details modular modal */}
{selectedBook && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div
className="fixed inset-0 bg-neutral-950/85 backdrop-blur-md transition-opacity"
<div
className="fixed inset-0 bg-neutral-950/85 backdrop-blur-md transition-opacity"
onClick={() => setSelectedBook(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-neutral-950 border border-neutral-900 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
<button
<button
onClick={() => setSelectedBook(null)}
className="absolute top-4 right-4 text-neutral-400 hover:text-amber-100 hover:bg-orange-950/80 px-2.5 py-1 rounded-md text-xs font-sans border border-neutral-800 transition-all cursor-pointer"
>
@ -311,19 +312,19 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 pt-4">
{/* Book Cover */}
<div className="col-span-1 md:col-span-4 flex flex-col items-center">
<div className="rounded-xl overflow-hidden shadow-2xl border border-orange-950/50 max-w-[260px] aspect-[2/3] w-full">
<img
src={selectedBook.coverUrl}
alt={selectedBook.title}
<img
src={selectedBook.coverUrl || undefined}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
{selectedBook.buyLink && (
<a
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
@ -352,7 +353,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
<h3 className="text-3xl font-serif text-amber-100">
{selectedBook.title}
</h3>
{selectedBook.subtitle && (
<h4 className="text-sm font-sans italic text-orange-400">
{selectedBook.subtitle}
@ -366,31 +367,15 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
</div>
</div>
{/* Embedded Spotify player */}
{/* Privacy-friendly Spotify player */}
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
<div className="space-y-2">
<p className="text-xs font-sans text-orange-400 flex items-center gap-2 italic">
<Heart className="w-3.5 h-3.5 fill-orange-400 text-orange-400" />
<span>Sinnliche Begleitmusik (Spotify Playlist)</span>
</p>
<div className="rounded-xl overflow-hidden bg-neutral-900 border border-neutral-900">
<iframe
src={`https://open.spotify.com/embed/playlist/${selectedBook.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allowFullScreen={false}
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
</div>
<SpotifyConsentPlayer playlist={selectedBook.spotifyPlaylistId} title={`Spotify-Playlist zu ${selectedBook.title}`} accentColor={accentColor} />
)}
</div>
</div>
</div>
</div>
</div>
@ -399,15 +384,15 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
{/* Project details modular modal */}
{selectedProject && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
<div
className="fixed inset-0 bg-neutral-950/85 backdrop-blur-md transition-opacity"
<div
className="fixed inset-0 bg-neutral-950/85 backdrop-blur-md transition-opacity"
onClick={() => setSelectedProject(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-neutral-950 border border-neutral-900 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-5xl p-6 md:p-8 space-y-6">
<button
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 right-4 text-neutral-400 hover:text-amber-100 hover:bg-orange-950/80 px-2.5 py-1 rounded-md text-xs font-sans border border-neutral-800 transition-all cursor-pointer"
>
@ -436,8 +421,8 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
<span className="font-bold" style={{ color: accentColor }}>{selectedProject.progress}%</span>
</div>
<div className="h-2 w-full bg-neutral-900 rounded-full overflow-hidden border border-neutral-800">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-md"
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-md"
style={{ width: `${selectedProject.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
@ -461,7 +446,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
</div>
</div>
</div>
</div>
</div>
</div>
@ -472,7 +457,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
<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) => (

View file

@ -1,23 +1,13 @@
import { Music } from "lucide-react";
import { Project } from "../types";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
interface ProjectDetailMediaProps {
project: Project;
accentColor: string;
}
function normalizeSpotifyPlaylistId(value?: string): string | null {
if (!value) return null;
const trimmed = value.trim();
const urlMatch = trimmed.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/);
const candidate = urlMatch?.[1] || trimmed;
return /^[a-zA-Z0-9]+$/.test(candidate) ? candidate : null;
}
export default function ProjectDetailMedia({ project, accentColor }: ProjectDetailMediaProps) {
const playlistId = normalizeSpotifyPlaylistId(project.spotifyPlaylistId);
if (!project.imageUrl && !playlistId) return null;
if (!project.imageUrl && !project.spotifyPlaylistId) return null;
return (
<div className="space-y-5">
@ -33,22 +23,12 @@ export default function ProjectDetailMedia({ project, accentColor }: ProjectDeta
</div>
)}
{playlistId && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider" style={{ color: accentColor }}>
<Music className="h-4 w-4" />
<span>Playlist zum Schreibprojekt</span>
</div>
<iframe
title={`Spotify-Playlist zu ${project.title}`}
src={`https://open.spotify.com/embed/playlist/${playlistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
className="rounded-xl border-0"
/>
</div>
{project.spotifyPlaylistId && (
<SpotifyConsentPlayer
playlist={project.spotifyPlaylistId}
title={`Spotify-Playlist zu ${project.title}`}
accentColor={accentColor}
/>
)}
</div>
);

View file

@ -1,12 +1,13 @@
import React, { useState } from "react";
import Markdown from "react-markdown";
import { AuthorProfile, Book, Project, LegalDocument } from "../types";
import {
Heart, Sparkles, BookOpen, ExternalLink, X, ArrowRight, Sun, Music
import {
Heart, Sparkles, BookOpen, ExternalLink, X, ArrowRight, Sun
} from "lucide-react";
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
interface ReneePortfolioProps {
data: AuthorProfile;
@ -29,7 +30,7 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans";
return (
<div
<div
className={`min-h-screen text-slate-100 ${fontClass} selection:bg-pink-500 selection:text-white overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
@ -49,14 +50,14 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
{/* Header / Hero */}
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-slate-800">
<div className="text-center max-w-4xl mx-auto space-y-6">
<div
<div
className="inline-flex items-center gap-2 px-4 py-1.5 bg-slate-800/80 border rounded-full text-xs tracking-wider mb-2 shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Heart className="w-3.5 h-3.5 fill-current animate-pulse" style={{ color: secondaryColor }} />
<span>{data.badgeText || "Herzklopfen & Gefühlsstark"}</span>
</div>
<h1 className="text-4xl md:text-6xl tracking-wide text-white uppercase font-bold drop-shadow-sm">
{data.heroTitle || data.name || "RENEE HEART"}
</h1>
@ -67,19 +68,19 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-sans">
<a
<a
href="#ueber-mich"
className="px-4 py-2 rounded-full border border-slate-700 bg-slate-800/60 hover:border-yellow-400 hover:text-yellow-300 transition-all text-slate-200 uppercase tracking-wider font-medium"
>
💖 {data.aboutTitle || "Über Renee Heart"}
</a>
<a
<a
href="#projekte"
className="px-4 py-2 rounded-full border border-slate-700 bg-slate-800/60 hover:border-yellow-400 hover:text-yellow-300 transition-all text-slate-200 uppercase tracking-wider font-medium"
>
✍️ {data.projectsTitle || "Aus der Schreibstube"}
</a>
<a
<a
href="#buecherregal"
className="px-4 py-2 rounded-full border border-slate-700 bg-slate-800/60 hover:border-yellow-400 hover:text-yellow-300 transition-all text-slate-200 uppercase tracking-wider font-medium"
>
@ -91,7 +92,7 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
{/* Main Content Area */}
<main className="max-w-6xl mx-auto px-4 py-16 space-y-24">
{/* Author Bio Section */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
@ -99,9 +100,9 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
{/* Outer yellow/pink glow */}
<div className="absolute -inset-1.5 rounded-2xl blur opacity-30 group-hover:opacity-70 transition duration-1000 group-hover:duration-200" style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }} />
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-slate-700 shadow-xl" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=400"}
alt={data.name}
<img
src={data.avatarUrl || undefined}
alt={data.name}
className="w-full h-full object-cover group-hover:scale-105 transition duration-500"
/>
</div>
@ -115,7 +116,7 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
<div className="prose prose-invert max-w-none text-slate-300 leading-relaxed space-y-4">
<p className="whitespace-pre-wrap">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-xl flex items-center gap-3 shadow-sm" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
@ -150,7 +151,7 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
@ -174,8 +175,8 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={project.id}
<button
key={project.id}
onClick={() => setSelectedProject(project)}
className="p-6 border rounded-xl transition-all duration-300 flex flex-col text-left w-full justify-between group cursor-pointer hover:scale-[1.01] focus:outline-none shadow-lg"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
@ -193,17 +194,17 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
{project.title}
</h3>
<div className="text-slate-300 text-sm line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
)
}}
>
{project.description}
@ -215,8 +216,8 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
</div>
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1.5 w-full bg-slate-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
@ -245,13 +246,13 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-yellow-500/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || "https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=300"}
<img
src={book.coverUrl || undefined}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent p-4 opacity-90 group-hover:opacity-100 transition-all duration-300 z-20">
<p className="text-white text-xs font-bold truncate">{book.title}</p>
<p className="text-[10px] font-sans mt-0.5 font-semibold flex items-center gap-1" style={{ color: secondaryColor }}>
@ -274,16 +275,16 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
)}
{data.customSectionContent && (
<div className="text-slate-300 text-sm md:text-base leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
/>
)
)
}}
>
{data.customSectionContent}
@ -298,11 +299,11 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
{/* Book Details Modal */}
{selectedBook && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-fade-in">
<div
<div
className="relative w-full max-w-2xl border rounded-2xl p-6 md:p-8 space-y-6 max-h-[90vh] overflow-y-auto shadow-2xl"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}40` }}
>
<button
<button
onClick={() => setSelectedBook(null)}
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white bg-slate-800/80 rounded-full transition-colors cursor-pointer"
>
@ -311,8 +312,8 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-start">
<div className="md:col-span-5 aspect-[2/3] rounded-xl overflow-hidden border border-slate-700 shadow-md">
<img
src={selectedBook.coverUrl}
<img
src={selectedBook.coverUrl || undefined}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
@ -338,44 +339,30 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
)}
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
/>
)
)
}}
>
{selectedBook.description}
</Markdown>
</div>
{/* Spotify Playlist player if configured */}
{/* Privacy-friendly Spotify player */}
{selectedBook.spotifyPlaylistId && (
<div className="pt-2">
<div className="flex items-center gap-1.5 text-xs text-slate-400 mb-2">
<Music className="w-3.5 h-3.5 text-pink-400" />
<span>Klangwelt zum Buch auf Spotify</span>
</div>
<iframe
style={{ borderRadius: "12px" }}
src={`https://open.spotify.com/embed/playlist/${selectedBook.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
<SpotifyConsentPlayer playlist={selectedBook.spotifyPlaylistId} title={`Spotify-Playlist zu ${selectedBook.title}`} accentColor={accentColor} />
)}
{selectedBook.buyLink && (
<div className="pt-2">
<a
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
@ -397,11 +384,11 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
{selectedProject && (
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/80 backdrop-blur-md animate-fade-in">
<div className="flex min-h-full items-center justify-center p-4">
<div
<div
className="relative w-full max-w-5xl border rounded-2xl p-6 md:p-8 my-8 space-y-6 shadow-2xl"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}40` }}
>
<button
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-white bg-slate-800/80 rounded-full transition-colors cursor-pointer"
>
@ -421,8 +408,8 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
<h3 className="text-2xl font-bold text-white">{selectedProject.title}</h3>
<div className="h-2 w-full bg-slate-900 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${selectedProject.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
@ -442,16 +429,16 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
</h4>
)}
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-yellow-400 hover:text-pink-400 font-medium"
{...props}
/>
)
)
}}
>
{selectedProject.description || "Für dieses Schreibprojekt wurden noch keine weiteren Details abgelegt."}
@ -470,7 +457,7 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
<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) => (

View file

@ -5,6 +5,7 @@ import { BookOpen, Star, HelpCircle, Award, Compass, ArrowRight, ExternalLink, M
import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
interface SciFiPortfolioProps {
data: AuthorProfile;
@ -27,7 +28,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans";
return (
<div
<div
className={`min-h-screen text-slate-100 ${fontClass} selection:bg-cyan-500 selection:text-white overflow-x-hidden relative`}
style={{ backgroundColor: bgColor }}
>
@ -41,11 +42,11 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{/* Decorative starry background with magical aura */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-950/40 via-transparent to-transparent -z-10 pointer-events-none" />
<div
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20"
<div
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20"
style={{ backgroundColor: accentColor }}
/>
<div
<div
className="absolute top-2/3 right-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none opacity-20"
style={{ backgroundColor: secondaryColor }}
/>
@ -54,40 +55,40 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
<header className="relative py-24 px-4 max-w-7xl mx-auto border-b border-slate-800/80">
<div className="text-center max-w-4xl mx-auto">
{/* Subtle magical/futuristic accent */}
<div
<div
className="inline-flex items-center gap-2 px-3.5 py-1 bg-slate-900/80 border rounded-full text-xs font-mono mb-6 tracking-widest uppercase shadow-sm"
style={{ borderColor: `${accentColor}40`, color: accentColor }}
>
<Star className="w-3.5 h-3.5 animate-pulse" style={{ color: accentColor }} />
<span>{data.badgeText || "Cyber-Mystisches Archive"}</span>
</div>
<h1 className="text-5xl md:text-7xl tracking-tight text-white mb-4 uppercase drop-shadow-md font-bold">
{data.heroTitle || data.name || "DANIEL HESSE"}
</h1>
<p className="font-mono text-xs md:text-sm tracking-widest uppercase mb-8" style={{ color: accentColor }}>
{data.heroSubtitle || "WELTENWANDLER & STERNENZEICHNER"}
</p>
<div
className="h-0.5 w-24 mx-auto rounded-full"
<div
className="h-0.5 w-24 mx-auto rounded-full"
style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
{/* Quick Section Navigation */}
<nav className="mt-8 flex flex-wrap justify-center gap-3 text-xs font-mono">
<a
<a
href="#ueber-mich"
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.aboutTitle || "Über mich"}
</a>
<a
<a
href="#projekte"
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.projectsTitle || "Aktuelle Projekte"}
</a>
<a
<a
href="#buecherregal"
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"
>
@ -99,20 +100,20 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{/* Main Container */}
<main className="max-w-7xl mx-auto px-4 py-16 space-y-24">
{/* Section 1: Über mich */}
<section id="ueber-mich" className="grid grid-cols-1 md:grid-cols-12 gap-12 items-center">
<div className="md:col-span-4 flex justify-center">
<div className="relative group">
{/* Outer neon ring */}
<div
<div
className="absolute -inset-1.5 rounded-2xl blur opacity-30 group-hover:opacity-70 transition duration-1000 group-hover:duration-200"
style={{ background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
<div className="relative w-64 h-64 md:w-80 md:h-80 rounded-2xl overflow-hidden border border-slate-800" style={{ backgroundColor: cardBgColor }}>
<img
src={data.avatarUrl || "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=400"}
alt={data.name}
<img
src={data.avatarUrl || undefined}
alt={data.name}
className="w-full h-full object-cover grayscale brightness-90 contrast-110 group-hover:scale-105 group-hover:grayscale-0 transition-all duration-700"
referrerPolicy="no-referrer"
/>
@ -127,7 +128,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
<div className="prose prose-invert max-w-none text-slate-300 leading-relaxed space-y-4">
<p className="whitespace-pre-wrap">{data.bio}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pt-4 font-mono text-xs">
{(data.bioTag1Label || data.bioTag1Value) && (
<div className="p-3 border rounded-lg flex items-center gap-3" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}20` }}>
@ -162,7 +163,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{/* Section 1.5: Spotlight / Neuerscheinung */}
{data.books?.find(b => b.isSpotlight) && (
<SpotlightSection
<SpotlightSection
book={data.books.find(b => b.isSpotlight)!}
title={data.spotlightTitle}
subtitle={data.spotlightSubtitle}
@ -189,8 +190,8 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.projects.map((project) => (
<button
key={project.id}
<button
key={project.id}
onClick={() => setSelectedProject(project)}
className="p-6 border rounded-xl transition-all duration-300 flex flex-col text-left w-full justify-between group cursor-pointer hover:scale-[1.01] focus:outline-none shadow-lg"
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}25` }}
@ -208,17 +209,17 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{project.title}
</h3>
<div className="text-slate-300 text-sm leading-relaxed line-clamp-3 markdown-body [&_p]:inline [&_p]:m-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-cyan-400 hover:text-cyan-300 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-cyan-400 hover:text-cyan-300 font-medium"
onClick={(e) => e.stopPropagation()}
{...props}
/>
)
)
}}
>
{project.description}
@ -231,8 +232,8 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{/* Custom glowing progress bar */}
<div className="mt-6 space-y-1.5 w-full">
<div className="h-1.5 w-full bg-slate-950/80 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-sm"
style={{ width: `${project.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
@ -261,13 +262,13 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}
>
<div className="absolute inset-0 bg-cyan-900/10 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none" />
<img
src={book.coverUrl || "https://images.unsplash.com/photo-1543002588-bfa74002ed7e?auto=format&fit=crop&q=80&w=300"}
<img
src={book.coverUrl || undefined}
alt={book.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 z-0"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent p-4 opacity-80 group-hover:opacity-100 transition-all duration-350 z-20">
<p className="text-white text-xs font-bold truncate">{book.title}</p>
<p className="text-[10px] font-mono mt-0.5" style={{ color: accentColor }}>Details auslesen <ArrowRight className="inline-block w-2.5 h-2.5 ml-0.5" /></p>
@ -291,16 +292,16 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
)}
{data.customSectionContent && (
<div className="text-slate-300 text-sm leading-relaxed markdown-body [&_p]:mb-4 [&_p:last-child]:mb-0">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-cyan-400 hover:text-cyan-300 font-medium"
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline text-cyan-400 hover:text-cyan-300 font-medium"
{...props}
/>
)
)
}}
>
{data.customSectionContent}
@ -316,16 +317,16 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{selectedBook && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
{/* Overlay background */}
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
onClick={() => setSelectedBook(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
{/* Close Button */}
<button
<button
onClick={() => setSelectedBook(null)}
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
>
@ -333,19 +334,19 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 pt-4">
{/* Book Cover */}
<div className="col-span-1 md:col-span-4 flex flex-col items-center">
<div className="rounded-xl overflow-hidden shadow-2xl border border-indigo-900/30 max-w-[260px] aspect-[2/3] w-full">
<img
src={selectedBook.coverUrl}
alt={selectedBook.title}
<img
src={selectedBook.coverUrl || undefined}
alt={selectedBook.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
{selectedBook.buyLink && (
<a
<a
href={selectedBook.buyLink}
target="_blank"
rel="noopener noreferrer"
@ -374,7 +375,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
<h3 className="text-3xl font-extrabold text-white">
{selectedBook.title}
</h3>
{selectedBook.subtitle && (
<h4 className="text-sm font-mono text-indigo-400 uppercase tracking-wider">
{selectedBook.subtitle}
@ -388,32 +389,15 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
</div>
</div>
{/* Integrated Spotify web player playlist if available */}
{/* Privacy-friendly Spotify player */}
{selectedBook.spotifyPlaylistId && selectedBook.spotifyPlaylistId.trim() !== "" && (
<div className="space-y-2">
<p className="text-xs font-mono text-indigo-400 flex items-center gap-2">
<Star className="w-3.5 h-3.5 animate-spin" />
<span>INTEGRIERTES ATMOSPHÄREN-SYSTEM (SPOTIFY PLAYLIST)</span>
</p>
<div className="rounded-xl overflow-hidden bg-slate-950 border border-slate-800">
<iframe
src={`https://open.spotify.com/embed/playlist/${selectedBook.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allowFullScreen={false}
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
className="bg-slate-950"
/>
</div>
</div>
<SpotifyConsentPlayer playlist={selectedBook.spotifyPlaylistId} title={`Spotify-Playlist zu ${selectedBook.title}`} accentColor={accentColor} />
)}
</div>
</div>
</div>
</div>
</div>
@ -423,16 +407,16 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
{selectedProject && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
{/* Overlay background */}
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
onClick={() => setSelectedProject(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-5xl p-6 md:p-8 space-y-6">
{/* Close Button */}
<button
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 right-4 text-slate-400 hover:text-white hover:bg-slate-800/80 px-2.5 py-1 rounded-md text-xs font-mono border border-slate-800 transition-all cursor-pointer"
>
@ -461,8 +445,8 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
<span className="font-bold" style={{ color: accentColor }}>{selectedProject.progress}%</span>
</div>
<div className="h-2 w-full bg-slate-950 rounded-full overflow-hidden border border-slate-800">
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-md"
<div
className="h-full rounded-full transition-all duration-1000 ease-out shadow-md"
style={{ width: `${selectedProject.progress}%`, background: `linear-gradient(to right, ${accentColor}, ${secondaryColor})` }}
/>
</div>
@ -486,7 +470,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
</div>
</div>
</div>
</div>
</div>
</div>
@ -497,7 +481,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
<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) => (

View file

@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { Music } from "lucide-react";
interface SpotifyConsentPlayerProps {
playlist: string;
title: string;
accentColor?: string;
}
function normalizePlaylistId(value: string): string | null {
const trimmed = value.trim();
const urlMatch = trimmed.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/);
const candidate = urlMatch?.[1] || trimmed;
return /^[a-zA-Z0-9]+$/.test(candidate) ? candidate : null;
}
export default function SpotifyConsentPlayer({ playlist, title, accentColor = "#34d399" }: SpotifyConsentPlayerProps) {
const [isLoaded, setIsLoaded] = useState(false);
const playlistId = normalizePlaylistId(playlist);
useEffect(() => {
setIsLoaded(false);
}, [playlist]);
if (!playlistId) return null;
if (isLoaded) {
return (
<div className="space-y-2">
<iframe
title={title}
src={`https://open.spotify.com/embed/playlist/${playlistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
className="rounded-xl border-0"
/>
<button
type="button"
onClick={() => setIsLoaded(false)}
className="text-[10px] text-slate-500 underline underline-offset-2 hover:text-slate-300 cursor-pointer"
>
Spotify-Player wieder deaktivieren
</button>
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-black/20 p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider" style={{ color: accentColor }}>
<Music className="h-4 w-4" />
<span>Spotify-Playlist</span>
</div>
<p className="text-xs leading-relaxed text-slate-400">
Der Player wird erst nach Ihrer Zustimmung von Spotify geladen. Dabei wird eine Verbindung zu Spotify hergestellt.
</p>
<button
type="button"
onClick={() => setIsLoaded(true)}
className="rounded-lg border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold text-white hover:bg-white/15 transition-colors cursor-pointer"
>
Spotify-Player laden
</button>
</div>
);
}

View file

@ -1,7 +1,8 @@
import React, { useState } from "react";
import React from "react";
import Markdown from "react-markdown";
import { Book } from "../types";
import { Sparkles, Calendar, ShoppingBag, Music, ExternalLink, Tag } from "lucide-react";
import { Sparkles, Calendar, ShoppingBag, ExternalLink, Tag } from "lucide-react";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
interface SpotlightSectionProps {
book: Book;
@ -20,13 +21,11 @@ export default function SpotlightSection({
secondaryColor,
cardBgColor,
}: SpotlightSectionProps) {
const [showSpotify, setShowSpotify] = useState(false);
// Helper to check if a release date is in the future
const isFutureRelease = (): boolean => {
if (!book.publishedYear) return false;
const str = String(book.publishedYear).trim();
// Check if it's a 4-digit year like "2027"
if (/^\d{4}$/.test(str)) {
return parseInt(str) > new Date().getFullYear();
@ -56,7 +55,7 @@ export default function SpotlightSection({
return (
<section id="spotlight" className="space-y-6 my-12">
<div className="flex items-center gap-3 border-b border-slate-800 pb-3">
<div
<div
className="p-2 rounded-xl flex items-center justify-center shadow-md"
style={{ backgroundColor: `${accentColor}20`, color: accentColor }}
>
@ -70,16 +69,16 @@ export default function SpotlightSection({
</div>
</div>
<div
<div
className="relative overflow-hidden border rounded-2xl p-6 md:p-8 shadow-2xl transition-all"
style={{
backgroundColor: cardBgColor,
style={{
backgroundColor: cardBgColor,
borderColor: `${accentColor}40`,
boxShadow: `0 20px 50px -10px ${accentColor}15`
}}
>
{/* Subtle Ambient Background Gradient */}
<div
<div
className="absolute -right-20 -bottom-20 w-80 h-80 rounded-full blur-3xl pointer-events-none opacity-20"
style={{ backgroundColor: accentColor }}
/>
@ -88,14 +87,14 @@ export default function SpotlightSection({
{/* Cover image column */}
<div className="md:col-span-4 flex flex-col items-center space-y-4">
<div className="relative group w-full max-w-[260px]">
<div
<div
className="absolute -inset-1 rounded-2xl blur-lg opacity-40 group-hover:opacity-75 transition duration-500"
style={{ backgroundColor: accentColor }}
/>
<div className="relative aspect-[2/3] w-full rounded-xl overflow-hidden border border-slate-700/60 shadow-2xl bg-slate-900">
<img
src={book.coverUrl}
alt={book.title}
<img
src={book.coverUrl || undefined}
alt={book.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
@ -120,10 +119,10 @@ export default function SpotlightSection({
{/* Details column */}
<div className="md:col-span-8 space-y-5">
<div className="flex flex-wrap items-center gap-2">
<span
<span
className="px-3 py-1 rounded-full text-xs font-mono font-bold tracking-wide uppercase border flex items-center gap-1.5 shadow-sm"
style={{
backgroundColor: isFuture ? `${secondaryColor}25` : `${accentColor}25`,
style={{
backgroundColor: isFuture ? `${secondaryColor}25` : `${accentColor}25`,
borderColor: isFuture ? secondaryColor : accentColor,
color: isFuture ? "#fff" : accentColor
}}
@ -156,8 +155,8 @@ export default function SpotlightSection({
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<Tag className="w-3.5 h-3.5 text-slate-500 mr-1" />
{book.genres.map((g, idx) => (
<span
key={idx}
<span
key={idx}
className="px-2.5 py-0.5 rounded-md text-[11px] font-mono border bg-slate-900/80 text-slate-300 border-slate-800"
>
{g}
@ -168,46 +167,27 @@ export default function SpotlightSection({
{/* Synopsis */}
<div className="prose prose-invert max-w-none text-slate-300 text-sm leading-relaxed space-y-3 pt-2 border-t border-slate-800/60">
<Markdown
components={{
<Markdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:opacity-80"
style={{ color: accentColor }}
{...props}
<a
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:opacity-80"
style={{ color: accentColor }}
{...props}
/>
)
)
}}
>
{book.description}
</Markdown>
</div>
{/* Spotify Player Toggle if present */}
{/* Privacy-friendly Spotify player */}
{book.spotifyPlaylistId && (
<div className="pt-2">
<button
onClick={() => setShowSpotify(!showSpotify)}
className="px-4 py-2 bg-slate-900 hover:bg-slate-850 border border-slate-700 rounded-xl text-xs font-mono text-slate-300 flex items-center gap-2 transition-all cursor-pointer"
>
<Music className="w-4 h-4 text-emerald-400" />
<span>{showSpotify ? "Playlist einklappen" : "Buch-Soundtrack (Spotify) anhören"}</span>
</button>
{showSpotify && (
<div className="mt-3 rounded-xl overflow-hidden border border-slate-800 shadow-xl bg-slate-950">
<iframe
src={`https://open.spotify.com/embed/playlist/${book.spotifyPlaylistId}?utm_source=generator&theme=0`}
width="100%"
height="152"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
)}
<SpotifyConsentPlayer playlist={book.spotifyPlaylistId} title={`Spotify-Playlist zu ${book.title}`} accentColor={accentColor} />
</div>
)}
</div>

View file

@ -3,7 +3,7 @@ import { AuthorData } from "./types";
export const defaultAuthorData: AuthorData = {
scifi: {
name: "Daniel Hesse",
avatarUrl: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300",
avatarUrl: "",
heroTitle: "DANIEL HESSE",
heroSubtitle: "WELTENWANDLER & STERNENZEICHNER",
bio: "Herzlich willkommen! Ich bin Daniel Hesse. Ich schreibe epische Fantasy und tiefgründige Science-Fiction, die dich an die Grenzen der Realität und weit darüber hinaus entführen. Schon als Kind faszinierten mich die unendlichen Weiten des Kosmos und die alten Sagen vergessener Völker. In meinen Büchern verschmelzen kalte, fortschrittliche Technologie und uralter, mystischer Zauber zu einer einzigartigen Atmosphäre. Wenn ich nicht gerade neue Galaxien am Laptop erschaffe, wandere ich durch dunkle Wälder oder beobachte mit meinem Teleskop den Nachthimmel.",
@ -28,7 +28,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_1",
title: "Caelum: Ruf der Sterne",
subtitle: "Arche-Chroniken I",
coverUrl: "https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Am Rande der bekannten Galaxie sucht die Besatzung der 'Caelum' nach einer neuen Heimat für die letzten Überlebenden der Erde. Doch der leere Raum zwischen den Sternen ist nicht so unbewohnt, wie sie dachten. Es wartet eine Bedrohung, die älter ist als die Zeit selbst, aber auch eine Macht, die das Verständnis der Menschheit revolutionieren könnte.",
publishedYear: 2024,
buyLink: "https://amazon.de",
@ -41,7 +41,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_2",
title: "Die Schatten von ThalÃassia",
subtitle: "Einzelband",
coverUrl: "https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "In der versunkenen Unterwasser-Metropole Thalassia ringen Magokraten und Kybernetiker um ein uraltes Artefakt. Ein junger Taucher stößt in den abyssalen Gräben auf ein grauenvolles Geheimnis, das das Schicksal beider Fraktionen besiegeln könnte.",
publishedYear: 2023,
buyLink: "https://amazon.de",
@ -80,7 +80,7 @@ export const defaultAuthorData: AuthorData = {
},
erotica: {
name: "Annie Slone",
avatarUrl: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=300",
avatarUrl: "",
heroTitle: "ANNIE SLONE",
heroSubtitle: "ELEGANT-SINNLICHE LITERATUR",
bio: "Willkommen in meiner privaten Welt der Versuchung. Unter dem Pseudonym Annie Slone schreibe ich stilvolle, hocherotische Romane und prickelnde Erzählungen. Meine Leidenschaft liegt darin, das feine Spiel zwischen Verlangen und Hingabe, Macht und Vertrauen in all seinen schillernden Facetten zu beleuchten. Meine Geschichten richten sich an Kenner anspruchsvoller Erotikliteratur, die Wert auf tiefgründige Charaktere, knisternde Spannung und ästhetische Beschreibungen legen. Treten Sie näher und lassen Sie sich entführen.",
@ -105,7 +105,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_er_1",
title: "Flüstern im Samt",
subtitle: "Velvet Trust I",
coverUrl: "https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Ein Blick in den überfüllten Ballsaal reicht aus, um Sophias wohlgeordnete Welt ins Wanken zu bringen. Der geheimnisvolle Kunsthändler Alexander bietet ihr einen Deal an, dem sie nicht widerstehen kann. Doch hinter den luxuriösen Fassaden verbirgt sich ein leidenschaftliches Abenteuer voller Geheimnisse, das ihre tiefsten Tabus herausfordern wird.",
publishedYear: 2025,
buyLink: "https://amazon.de",
@ -116,7 +116,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_er_2",
title: "Die Maske des Begehrens",
subtitle: "Erotischer Thriller",
coverUrl: "https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "In Venedigs geheimem Karnevalskreisen kreuzen sich die Wege zweier Fremder, versteckt hinter venezianischen Seidenmasken. Ein sinnliches Versteckspiel beginnt, bei dem Identitäten verblassen und nur noch die unkontrollierbare sexuelle Spannung regiert.",
publishedYear: 2024,
buyLink: "https://amazon.de",
@ -155,7 +155,7 @@ export const defaultAuthorData: AuthorData = {
},
clara: {
name: "Clara Finch",
avatarUrl: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=300",
avatarUrl: "",
heroTitle: "CLARA FINCH",
heroSubtitle: "LYRIK, ESSAYS & NATURPHILOSOPHIE",
bio: "Willkommen auf meiner kreativen Lichtung. Ich bin Clara Finch. Unter diesem Namen widme ich mich der lyrischen Poesie, den feinen Essays und naturphilosophischen Betrachtungen über die Verbindung von Mensch und Erde. Meine Texte entstehen oft auf langen Spaziergängen durch neblige Täler, dichte Wälder und karge Küstenstreifen. Sie fangen die Vergänglichkeit des Augenblicks ein – sanft wie Malve, tief verwurzelt wie Salbei. Hier teile ich meine gedruckten Werke, laufenden Notizbücher und klanglichen Inspirationen mit Ihnen. Treten Sie leise ein und verweilen Sie einen Moment.",
@ -180,7 +180,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_cl_1",
title: "Die Stille der Halme",
subtitle: "Gedichte",
coverUrl: "https://images.unsplash.com/photo-1501504905252-473c47e087f8?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Mein erster veröffentlichter Gedichtband, der den Leser auf eine kontemplative Reise durch unberührte Landschaften, stille Morgenstunden und das leise Rauschen der Felder entführt. Jedes Wort ist sorgsam wie ein seltener Kieselstein ausgewählt, um das Unsagbare im Alltäglichen spürbar zu machen.",
publishedYear: 2025,
buyLink: "https://amazon.de",
@ -191,7 +191,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_cl_2",
title: "Knospen im Frost",
subtitle: "Prosa-Miniaturen",
coverUrl: "https://images.unsplash.com/photo-1448375240586-882707db888b?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Eine Sammlung feinsinniger Prosa-Skizzen, die sich mit der Resilienz des Lebens inmitten winterlicher Kälte befassen. Metaphern aus Flora und Fauna zeichnen ein zärtliches Porträt des menschlichen Geistes in Zeiten des Umbruchs.",
publishedYear: 2024,
buyLink: "https://amazon.de",
@ -230,7 +230,7 @@ export const defaultAuthorData: AuthorData = {
},
renee: {
name: "Renee Heart",
avatarUrl: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=300",
avatarUrl: "",
heroTitle: "RENEE HEART",
heroSubtitle: "HERZENSGESCHICHTEN & EMOTIONALE ROMANE",
bio: "Herzlich willkommen in meinem literarischen Zuhause. Ich bin Renee Heart. Ich schreibe herzerwärmende, gefühlvolle Romane über die großen Wendepunkte im Leben, tiefe Freundschaften, unvergessliche Liebesgeschichten und den Mut, dem eigenen Herzen zu folgen. Meine Geschichten handeln von echten Emotionen, leisen wie auch stürmischen Momenten und Charakteren, die man sofort ins Herz schließt. Lassen Sie sich von meinen Büchern berühren und begleiten!",
@ -255,7 +255,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_rh_1",
title: "Wo das Herz Zuhause ist",
subtitle: "Herzensroman",
coverUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Als Maya das alte Haus ihrer Großmutter an der Küste erbt, ahnt sie nicht, dass der Neuanfang ihr ganzes Leben verändern wird. Zwischen stürmischen Wellen und warmem Tee findet sie nicht nur vergessene Familiengeheimnisse, sondern auch die Liebe ihres Lebens.",
publishedYear: 2025,
buyLink: "https://amazon.de",
@ -266,7 +266,7 @@ export const defaultAuthorData: AuthorData = {
id: "book_rh_2",
title: "Sterne über den Dächern",
subtitle: "Romantische Komödie",
coverUrl: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&fit=crop&q=80&w=600",
coverUrl: "",
description: "Eine humorvolle und tiefgründige Geschichte über zwei Gegensätze, die sich in einer nächtlichen Sternwarte näherkommen als je geahnt.",
publishedYear: 2024,
buyLink: "https://amazon.de",