feat: require consent for Spotify embeds
This commit is contained in:
parent
91953bf162
commit
3abaaba869
10 changed files with 597 additions and 599 deletions
33
server.ts
33
server.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
id: "book_" + Date.now(),
|
||||
title: bookForm.title || "Unbenanntes Buch",
|
||||
subtitle: bookForm.subtitle || "",
|
||||
coverUrl: bookForm.coverUrl || "https://images.unsplash.com/photo-1543002588-bfa74002ed7e?auto=format&fit=crop&q=80&w=400",
|
||||
coverUrl: bookForm.coverUrl || "",
|
||||
description: bookForm.description || "",
|
||||
publishedYear: bookForm.publishedYear || new Date().getFullYear(),
|
||||
buyLink: bookForm.buyLink || "",
|
||||
|
|
@ -598,7 +598,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
<h3 className="text-xs font-mono text-indigo-400 uppercase tracking-wider">Aktive Identität</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={profile.avatarUrl}
|
||||
src={profile.avatarUrl || undefined}
|
||||
alt={profile.name}
|
||||
className="w-10 h-10 rounded-full object-cover border border-slate-700"
|
||||
referrerPolicy="no-referrer"
|
||||
|
|
@ -1479,7 +1479,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
<div key={b.id} className="p-4 bg-slate-950 border border-slate-800 rounded-xl space-y-3 flex flex-col justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="w-24 aspect-[2/3] mx-auto rounded-lg overflow-hidden border border-slate-800 bg-slate-900 relative">
|
||||
<img src={b.coverUrl} alt={b.title} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
|
||||
<img src={b.coverUrl || undefined} alt={b.title} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
|
||||
{b.isSpotlight && (
|
||||
<span className="absolute top-1 right-1 bg-amber-500 text-slate-950 p-1 rounded-full shadow-md" title="Spotlight / Neuerscheinung">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -98,7 +99,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
|
|||
<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"}
|
||||
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"
|
||||
|
|
@ -245,7 +246,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
|
|||
>
|
||||
<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"}
|
||||
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"
|
||||
|
|
@ -316,7 +317,7 @@ export default function ClaraPortfolio({ data, legalDocuments = [] }: ClaraPortf
|
|||
<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}
|
||||
src={selectedBook.coverUrl || undefined}
|
||||
alt={selectedBook.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
|
|
@ -366,25 +367,9 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -98,7 +99,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
|
|||
<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"}
|
||||
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"
|
||||
|
|
@ -245,7 +246,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
|
|||
>
|
||||
<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"}
|
||||
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"
|
||||
|
|
@ -316,7 +317,7 @@ export default function EroticaPortfolio({ data, legalDocuments = [] }: EroticaP
|
|||
<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}
|
||||
src={selectedBook.coverUrl || undefined}
|
||||
alt={selectedBook.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
|
|
@ -366,25 +367,9 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{project.spotifyPlaylistId && (
|
||||
<SpotifyConsentPlayer
|
||||
playlist={project.spotifyPlaylistId}
|
||||
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"
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ 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
|
||||
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;
|
||||
|
|
@ -100,7 +101,7 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
|
|||
<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"}
|
||||
src={data.avatarUrl || undefined}
|
||||
alt={data.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition duration-500"
|
||||
/>
|
||||
|
|
@ -246,7 +247,7 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
|
|||
>
|
||||
<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"}
|
||||
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"
|
||||
|
|
@ -312,7 +313,7 @@ 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}
|
||||
src={selectedBook.coverUrl || undefined}
|
||||
alt={selectedBook.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
|
|
@ -354,23 +355,9 @@ export default function ReneePortfolio({ data, legalDocuments = [] }: ReneePortf
|
|||
</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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -111,7 +112,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
|||
/>
|
||||
<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"}
|
||||
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"
|
||||
|
|
@ -262,7 +263,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
|||
>
|
||||
<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"}
|
||||
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"
|
||||
|
|
@ -338,7 +339,7 @@ export default function SciFiPortfolio({ data, legalDocuments = [] }: SciFiPortf
|
|||
<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}
|
||||
src={selectedBook.coverUrl || undefined}
|
||||
alt={selectedBook.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
|
|
@ -388,26 +389,9 @@ 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>
|
||||
|
|
|
|||
68
src/components/SpotifyConsentPlayer.tsx
Normal file
68
src/components/SpotifyConsentPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,8 +21,6 @@ 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;
|
||||
|
|
@ -94,7 +93,7 @@ export default function SpotlightSection({
|
|||
/>
|
||||
<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}
|
||||
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"
|
||||
|
|
@ -185,29 +184,10 @@ export default function SpotlightSection({
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue