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:
@@ -20,11 +20,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { title, content } = await req.json()
|
||||
const { title, content, author } = await req.json()
|
||||
if (!title?.trim() || !content?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Titel und Inhalt sind erforderlich' },
|
||||
@@ -34,8 +30,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare('INSERT INTO memories (title, content) VALUES (?, ?)')
|
||||
.run(title.trim(), content.trim())
|
||||
.prepare('INSERT INTO memories (title, content, author) VALUES (?, ?, ?)')
|
||||
.run(title.trim(), content.trim(), author?.trim() || null)
|
||||
const memory = db
|
||||
.prepare('SELECT * FROM memories WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
|
||||
@@ -19,6 +19,27 @@
|
||||
}
|
||||
|
||||
@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 */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import HeroSection from '@/components/HeroSection'
|
||||
import PhotoSlideshow from '@/components/PhotoSlideshow'
|
||||
import PhotoGallery from '@/components/PhotoGallery'
|
||||
import MemorySection from '@/components/MemorySection'
|
||||
import WriteSection from '@/components/WriteSection'
|
||||
import MusicPlayer from '@/components/MusicPlayer'
|
||||
import VideoGallery from '@/components/VideoGallery'
|
||||
|
||||
@@ -81,6 +82,9 @@ export default async function HomePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Write section – public */}
|
||||
<WriteSection />
|
||||
|
||||
{/* Memories */}
|
||||
<MemorySection memories={memories} />
|
||||
|
||||
|
||||
@@ -18,23 +18,69 @@ export default function HeroSection({ heroPhoto }: HeroSectionProps) {
|
||||
alt="Maria Malejka"
|
||||
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 className="absolute inset-0 bg-gradient-to-br from-amber-950 via-stone-900 to-stone-950">
|
||||
{/* Subtle pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-5"
|
||||
<div className="absolute inset-0" style={{ backgroundColor: '#060304' }}>
|
||||
{/* Animated orbs */}
|
||||
<motion.div
|
||||
className="absolute rounded-full blur-[120px]"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Vignette */}
|
||||
<div className="absolute inset-0 bg-radial-gradient pointer-events-none" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 text-center px-6 max-w-3xl mx-auto">
|
||||
<motion.div
|
||||
@@ -42,36 +88,44 @@ export default function HeroSection({ heroPhoto }: HeroSectionProps) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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
|
||||
</p>
|
||||
|
||||
<h1 className="font-cormorant text-7xl sm:text-8xl md:text-9xl font-light text-white leading-none mb-2"
|
||||
style={{ textShadow: '0 2px 40px rgba(0,0,0,0.5)' }}
|
||||
<h1
|
||||
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
|
||||
</h1>
|
||||
<h1 className="font-cormorant text-5xl sm:text-6xl md:text-7xl font-light text-amber-200 leading-none mb-10"
|
||||
style={{ textShadow: '0 2px 30px rgba(0,0,0,0.4)' }}
|
||||
<h1
|
||||
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
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
<div className="h-px w-12 bg-amber-400/30" />
|
||||
<span className="text-amber-400/50 text-lg">✦</span>
|
||||
<div className="h-px w-12 bg-amber-400/30" />
|
||||
<div className="flex items-center justify-center gap-4 mb-10">
|
||||
<div className="h-px w-16 bg-amber-400/25" />
|
||||
<span className="text-amber-400/40 text-lg">✦</span>
|
||||
<div className="h-px w-16 bg-amber-400/25" />
|
||||
</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
|
||||
</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 —
|
||||
</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 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.2, duration: 2 }}
|
||||
@@ -85,11 +139,11 @@ export default function HeroSection({ heroPhoto }: HeroSectionProps) {
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<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] }}
|
||||
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} />
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
@@ -61,6 +61,9 @@ export default function MemorySection({ memories }: { memories: Memory[] }) {
|
||||
|
||||
<p className="mt-6 text-xs text-warm-brown-light font-lora">
|
||||
{formatDate(memory.created_at)}
|
||||
{memory.author && (
|
||||
<span className="ml-2 text-warm-brown-light/60">— {memory.author}</span>
|
||||
)}
|
||||
</p>
|
||||
</motion.article>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -46,4 +46,11 @@ function initDb(db: DatabaseSync) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Memory = {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
author: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user