feat: dark cinematic hero + public memory write section

- HeroSection: animated warmgold/burgundy/indigo orbs on #060304 bg, film grain CSS overlay, larger glowing typography
- WriteSection: new public component – collapsible form (name optional, title, textarea) posts to /api/memories without auth, shows success state and refreshes page
- API: remove isAdmin() guard from POST /api/memories, accept author field
- DB: migration adds author TEXT column to memories (try/catch for existing DBs)
- Types: add author: string | null to Memory type
- MemorySection: display "— [Name]" beneath date when author is set
- globals.css: grain keyframe animation for film-texture overlay

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-02-16 01:45:57 +01:00
parent 8b4dc2e7e6
commit 279a07e4eb
8 changed files with 303 additions and 30 deletions
+3 -7
View File
@@ -20,11 +20,7 @@ export async function GET() {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!isAdmin()) { const { title, content, author } = await req.json()
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { title, content } = await req.json()
if (!title?.trim() || !content?.trim()) { if (!title?.trim() || !content?.trim()) {
return NextResponse.json( return NextResponse.json(
{ error: 'Titel und Inhalt sind erforderlich' }, { error: 'Titel und Inhalt sind erforderlich' },
@@ -34,8 +30,8 @@ export async function POST(req: NextRequest) {
const db = getDb() const db = getDb()
const result = db const result = db
.prepare('INSERT INTO memories (title, content) VALUES (?, ?)') .prepare('INSERT INTO memories (title, content, author) VALUES (?, ?, ?)')
.run(title.trim(), content.trim()) .run(title.trim(), content.trim(), author?.trim() || null)
const memory = db const memory = db
.prepare('SELECT * FROM memories WHERE id = ?') .prepare('SELECT * FROM memories WHERE id = ?')
.get(result.lastInsertRowid) .get(result.lastInsertRowid)
+21
View File
@@ -19,6 +19,27 @@
} }
@layer utilities { @layer utilities {
/* Film grain overlay */
.grain-overlay {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
background-size: 200px 200px;
animation: grain 0.8s steps(1) infinite;
}
@keyframes grain {
0% { background-position: 0 0; }
10% { background-position: -50px -30px; }
20% { background-position: 30px -60px; }
30% { background-position: -70px 20px; }
40% { background-position: 60px -10px; }
50% { background-position: -20px 50px; }
60% { background-position: 40px 30px; }
70% { background-position: -60px -40px; }
80% { background-position: 20px 60px; }
90% { background-position: -40px -20px; }
100% { background-position: 0 0; }
}
/* Custom scrollbar */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 5px; width: 5px;
+4
View File
@@ -4,6 +4,7 @@ import HeroSection from '@/components/HeroSection'
import PhotoSlideshow from '@/components/PhotoSlideshow' import PhotoSlideshow from '@/components/PhotoSlideshow'
import PhotoGallery from '@/components/PhotoGallery' import PhotoGallery from '@/components/PhotoGallery'
import MemorySection from '@/components/MemorySection' import MemorySection from '@/components/MemorySection'
import WriteSection from '@/components/WriteSection'
import MusicPlayer from '@/components/MusicPlayer' import MusicPlayer from '@/components/MusicPlayer'
import VideoGallery from '@/components/VideoGallery' import VideoGallery from '@/components/VideoGallery'
@@ -81,6 +82,9 @@ export default async function HomePage() {
</section> </section>
)} )}
{/* Write section public */}
<WriteSection />
{/* Memories */} {/* Memories */}
<MemorySection memories={memories} /> <MemorySection memories={memories} />
+77 -23
View File
@@ -18,23 +18,69 @@ export default function HeroSection({ heroPhoto }: HeroSectionProps) {
alt="Maria Malejka" alt="Maria Malejka"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/40 to-black/70" /> <div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/40 to-black/80" />
{/* Grain overlay */}
<div className="absolute inset-0 grain-overlay opacity-30 pointer-events-none" />
</div> </div>
) : ( ) : (
<div className="absolute inset-0 bg-gradient-to-br from-amber-950 via-stone-900 to-stone-950"> <div className="absolute inset-0" style={{ backgroundColor: '#060304' }}>
{/* Subtle pattern */} {/* Animated orbs */}
<div <motion.div
className="absolute inset-0 opacity-5" className="absolute rounded-full blur-[120px]"
style={{ style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23C4A04A' fill-opacity='1'%3E%3Cpath d='M20 20.5V18H0v5h5v5H0v5h20v-2.5c0-.83-.36-1.58-.93-2.1L20 26.5V20.5zm0-13V5H0v5h5v5H0v5h20V17.5c0-.83-.36-1.58-.93-2.1L20 13V7.5zM20 39V37H0v5h5v-5h15zM40 20.5V18H20v5h5v5h-5v5h20v-2.5c0-.83-.36-1.58-.93-2.1L40 26.5V20.5zm0-13V5H20v5h5v5h-5v5h20V17.5c0-.83-.36-1.58-.93-2.1L40 13V7.5zM40 39V37H20v5h5v-5h15z'/%3E%3C/g%3E%3C/svg%3E")`, width: '70vw',
height: '70vw',
maxWidth: 900,
maxHeight: 900,
background: 'rgba(196,160,74,0.10)',
top: '-20%',
left: '-20%',
}} }}
animate={{
x: [0, 80, -40, 0],
y: [0, 60, -30, 0],
}}
transition={{ duration: 28, repeat: Infinity, ease: 'easeInOut' }}
/> />
<motion.div
className="absolute rounded-full blur-[140px]"
style={{
width: '60vw',
height: '60vw',
maxWidth: 800,
maxHeight: 800,
background: 'rgba(120,30,30,0.14)',
bottom: '-15%',
right: '-15%',
}}
animate={{
x: [0, -60, 50, 0],
y: [0, -50, 40, 0],
}}
transition={{ duration: 34, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute rounded-full blur-[160px]"
style={{
width: '50vw',
height: '50vw',
maxWidth: 700,
maxHeight: 700,
background: 'rgba(50,40,90,0.10)',
top: '30%',
left: '30%',
}}
animate={{
x: [0, 40, -60, 0],
y: [0, -40, 50, 0],
}}
transition={{ duration: 40, repeat: Infinity, ease: 'easeInOut' }}
/>
{/* Grain overlay */}
<div className="absolute inset-0 grain-overlay opacity-40 pointer-events-none" />
</div> </div>
)} )}
{/* Vignette */}
<div className="absolute inset-0 bg-radial-gradient pointer-events-none" />
{/* Content */} {/* Content */}
<div className="relative z-10 text-center px-6 max-w-3xl mx-auto"> <div className="relative z-10 text-center px-6 max-w-3xl mx-auto">
<motion.div <motion.div
@@ -42,36 +88,44 @@ export default function HeroSection({ heroPhoto }: HeroSectionProps) {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.5, ease: 'easeOut' }} transition={{ duration: 1.5, ease: 'easeOut' }}
> >
<p className="text-amber-200/70 text-xs tracking-[0.4em] uppercase mb-8 font-lora"> <p className="text-amber-200/60 text-xs tracking-[0.5em] uppercase mb-12 font-lora">
In liebevoller Erinnerung In liebevoller Erinnerung
</p> </p>
<h1 className="font-cormorant text-7xl sm:text-8xl md:text-9xl font-light text-white leading-none mb-2" <h1
style={{ textShadow: '0 2px 40px rgba(0,0,0,0.5)' }} className="font-cormorant text-8xl sm:text-9xl md:text-[11rem] font-light text-white leading-none mb-4"
style={{
textShadow:
'0 0 80px rgba(196,160,74,0.25), 0 2px 40px rgba(0,0,0,0.6)',
}}
> >
Maria Maria
</h1> </h1>
<h1 className="font-cormorant text-5xl sm:text-6xl md:text-7xl font-light text-amber-200 leading-none mb-10" <h1
style={{ textShadow: '0 2px 30px rgba(0,0,0,0.4)' }} className="font-cormorant text-6xl sm:text-7xl md:text-8xl font-light text-amber-200 leading-none mb-14"
style={{
textShadow:
'0 0 60px rgba(196,160,74,0.20), 0 2px 30px rgba(0,0,0,0.5)',
}}
> >
Malejka Malejka
</h1> </h1>
<div className="flex items-center justify-center gap-4 mb-8"> <div className="flex items-center justify-center gap-4 mb-10">
<div className="h-px w-12 bg-amber-400/30" /> <div className="h-px w-16 bg-amber-400/25" />
<span className="text-amber-400/50 text-lg"></span> <span className="text-amber-400/40 text-lg"></span>
<div className="h-px w-12 bg-amber-400/30" /> <div className="h-px w-16 bg-amber-400/25" />
</div> </div>
<p className="font-lora text-amber-100/80 text-base sm:text-lg tracking-[0.2em]"> <p className="font-lora text-amber-100/70 text-base sm:text-lg tracking-[0.25em]">
29. November 1944 29. November 1944
</p> </p>
<p className="font-lora text-amber-100/50 text-sm tracking-[0.15em] mt-1"> <p className="font-lora text-amber-100/40 text-sm tracking-[0.2em] mt-2">
10. Februar 2026 10. Februar 2026
</p> </p>
<motion.p <motion.p
className="mt-12 font-cormorant italic text-xl sm:text-2xl text-amber-200/70 max-w-lg mx-auto leading-relaxed" className="mt-14 font-cormorant italic text-xl sm:text-2xl text-amber-200/60 max-w-lg mx-auto leading-relaxed"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 1.2, duration: 2 }} transition={{ delay: 1.2, duration: 2 }}
@@ -85,11 +139,11 @@ export default function HeroSection({ heroPhoto }: HeroSectionProps) {
{/* Scroll indicator */} {/* Scroll indicator */}
<motion.div <motion.div
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-amber-300/50 flex flex-col items-center gap-2" className="absolute bottom-8 left-1/2 -translate-x-1/2 text-amber-300/40 flex flex-col items-center gap-2"
animate={{ y: [0, 8, 0] }} animate={{ y: [0, 8, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }} transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
> >
<span className="text-xs tracking-widest text-amber-300/40 font-lora">scroll</span> <span className="text-xs tracking-widest text-amber-300/30 font-lora">scroll</span>
<ChevronDown size={20} /> <ChevronDown size={20} />
</motion.div> </motion.div>
</section> </section>
+3
View File
@@ -61,6 +61,9 @@ export default function MemorySection({ memories }: { memories: Memory[] }) {
<p className="mt-6 text-xs text-warm-brown-light font-lora"> <p className="mt-6 text-xs text-warm-brown-light font-lora">
{formatDate(memory.created_at)} {formatDate(memory.created_at)}
{memory.author && (
<span className="ml-2 text-warm-brown-light/60"> {memory.author}</span>
)}
</p> </p>
</motion.article> </motion.article>
))} ))}
+187
View File
@@ -0,0 +1,187 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { PenLine, CheckCircle } from 'lucide-react'
import { useRouter } from 'next/navigation'
export default function WriteSection() {
const router = useRouter()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [submitting, setSubmitting] = useState(false)
const [done, setDone] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!title.trim() || !content.trim()) {
setError('Bitte Titel und Text ausfüllen.')
return
}
setError('')
setSubmitting(true)
try {
const res = await fetch('/api/memories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, author: name || null }),
})
if (!res.ok) throw new Error('Fehler beim Speichern')
setDone(true)
router.refresh()
setTimeout(() => {
setDone(false)
setOpen(false)
setName('')
setTitle('')
setContent('')
}, 2800)
} catch {
setError('Etwas ist schiefgelaufen. Bitte nochmal versuchen.')
} finally {
setSubmitting(false)
}
}
return (
<section className="py-16 px-4" style={{ background: 'rgba(6,3,4,0.96)' }}>
<div className="max-w-2xl mx-auto">
<AnimatePresence mode="wait">
{!open && !done && (
<motion.div
key="invite"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<p className="font-cormorant italic text-amber-100/60 text-2xl sm:text-3xl mb-6 leading-relaxed">
Hast du eine Erinnerung,<br />die du teilen möchtest?
</p>
<button
onClick={() => setOpen(true)}
className="inline-flex items-center gap-3 px-7 py-3 border border-amber-400/30 text-amber-300/70 hover:text-amber-200 hover:border-amber-400/60 font-lora text-sm tracking-widest uppercase transition-all duration-300 rounded-full"
>
<PenLine size={15} />
Schreibe ihr etwas
</button>
</motion.div>
)}
{open && !done && (
<motion.div
key="form"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
className="overflow-hidden"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<div className="text-center mb-8">
<h2 className="font-cormorant italic text-3xl sm:text-4xl text-amber-100/80 mb-3">
Schreibe ihr etwas
</h2>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-12 bg-amber-400/20" />
<span className="text-amber-400/30 text-lg"></span>
<div className="h-px w-12 bg-amber-400/20" />
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Wie heißt du? (optional)"
className="w-full bg-transparent border-b border-amber-400/20 focus:border-amber-400/50 outline-none text-amber-100/60 placeholder-amber-100/25 font-lora text-sm py-2 transition-colors duration-300"
/>
</div>
<div>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Titel deiner Erinnerung …"
required
className="w-full bg-transparent border-b border-amber-400/20 focus:border-amber-400/50 outline-none text-amber-200/80 placeholder-amber-100/25 font-cormorant italic text-2xl py-2 transition-colors duration-300"
/>
</div>
<div>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="Deine Worte …"
required
rows={6}
className="w-full bg-white/[0.03] border border-amber-400/15 focus:border-amber-400/40 focus:ring-1 focus:ring-amber-400/20 outline-none rounded-xl p-5 text-amber-100/70 placeholder-amber-100/20 font-cormorant italic text-lg leading-relaxed resize-none transition-all duration-300"
/>
</div>
{error && (
<p className="text-red-400/70 font-lora text-sm">{error}</p>
)}
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={() => setOpen(false)}
className="text-amber-100/30 hover:text-amber-100/60 font-lora text-sm tracking-wider transition-colors duration-200"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className="px-8 py-2.5 bg-amber-400/10 hover:bg-amber-400/20 border border-amber-400/30 hover:border-amber-400/50 text-amber-300/80 hover:text-amber-200 font-lora text-sm tracking-widest uppercase rounded-full transition-all duration-300 disabled:opacity-40 disabled:cursor-not-allowed"
>
{submitting ? 'Speichern …' : 'Teilen'}
</button>
</div>
</form>
</motion.div>
</motion.div>
)}
{done && (
<motion.div
key="done"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5 }}
className="text-center py-8"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15 }}
className="flex justify-center mb-5"
>
<CheckCircle size={40} className="text-amber-400/60" />
</motion.div>
<p className="font-cormorant italic text-amber-100/70 text-2xl sm:text-3xl leading-relaxed">
Danke für deine Worte.
</p>
<p className="font-lora text-amber-100/40 text-sm mt-3 tracking-wider">
Deine Erinnerung wurde gespeichert.
</p>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
)
}
+7
View File
@@ -46,4 +46,11 @@ function initDb(db: DatabaseSync) {
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
`) `)
// Migration: add author column if missing
try {
db.exec(`ALTER TABLE memories ADD COLUMN author TEXT`)
} catch {
// Column already exists ignore
}
} }
+1
View File
@@ -2,6 +2,7 @@ export type Memory = {
id: number id: number
title: string title: string
content: string content: string
author: string | null
created_at: string created_at: string
updated_at: string updated_at: string
} }