Initial commit: Maria Malejka memorial website
Next.js 14 + node:sqlite memorial site with: - Hero section, photo slideshow & gallery - Memory/thoughts editor (admin) - Music player with upload - Video gallery - Docker Compose deployment - Responsive warm earth tone design
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# Passwort für den Adminbereich
|
||||||
|
ADMIN_PASSWORD=change-me-please
|
||||||
|
|
||||||
|
# Datenverzeichnis (Uploads & Datenbank)
|
||||||
|
DATA_DIR=/data
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Data (uploads & database)
|
||||||
|
/data/
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Runner ----
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Standalone output
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
RUN mkdir -p /data && chown nextjs:nodejs /data
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
oma:
|
||||||
|
build: .
|
||||||
|
container_name: oma-memorial
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- oma_data:/data
|
||||||
|
environment:
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
|
||||||
|
- DATA_DIR=/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
oma_data:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
Generated
+1671
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "oma-memorial",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "^11.2.0",
|
||||||
|
"lucide-react": "^0.400.0",
|
||||||
|
"next": "14.2.5",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"autoprefixer": "^10",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
LogOut,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Film,
|
||||||
|
Music,
|
||||||
|
FileText,
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
type Memory = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaItem = {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
original_name: string | null
|
||||||
|
type: 'photo' | 'video' | 'music'
|
||||||
|
caption: string | null
|
||||||
|
sort_order: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [authed, setAuthed] = useState<boolean | null>(null)
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [loginError, setLoginError] = useState('')
|
||||||
|
const [loginLoading, setLoginLoading] = useState(false)
|
||||||
|
|
||||||
|
const [memories, setMemories] = useState<Memory[]>([])
|
||||||
|
const [photos, setPhotos] = useState<MediaItem[]>([])
|
||||||
|
const [videos, setVideos] = useState<MediaItem[]>([])
|
||||||
|
const [music, setMusic] = useState<MediaItem[]>([])
|
||||||
|
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadCaption, setUploadCaption] = useState('')
|
||||||
|
const [uploadStatus, setUploadStatus] = useState('')
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
|
||||||
|
const [editingMemory, setEditingMemory] = useState<Memory | null>(null)
|
||||||
|
const [newMemory, setNewMemory] = useState({ title: '', content: '' })
|
||||||
|
const [showNewMemory, setShowNewMemory] = useState(false)
|
||||||
|
const [savingMemory, setSavingMemory] = useState(false)
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
const [memoriesRes, mediaRes] = await Promise.all([
|
||||||
|
fetch('/api/memories'),
|
||||||
|
fetch('/api/media'),
|
||||||
|
])
|
||||||
|
const [memoriesData, mediaData] = await Promise.all([
|
||||||
|
memoriesRes.json(),
|
||||||
|
mediaRes.json(),
|
||||||
|
])
|
||||||
|
setMemories(Array.isArray(memoriesData) ? memoriesData : [])
|
||||||
|
const items: MediaItem[] = Array.isArray(mediaData) ? mediaData : []
|
||||||
|
setPhotos(items.filter((m) => m.type === 'photo'))
|
||||||
|
setVideos(items.filter((m) => m.type === 'video'))
|
||||||
|
setMusic(items.filter((m) => m.type === 'music'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
setAuthed(d.authed)
|
||||||
|
if (d.authed) loadData()
|
||||||
|
})
|
||||||
|
.catch(() => setAuthed(false))
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const login = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoginLoading(true)
|
||||||
|
setLoginError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setAuthed(true)
|
||||||
|
loadData()
|
||||||
|
} else {
|
||||||
|
setLoginError('Falsches Passwort')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoginLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await fetch('/api/auth', { method: 'DELETE' })
|
||||||
|
setAuthed(false)
|
||||||
|
setPassword('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
if (uploadCaption) formData.append('caption', uploadCaption)
|
||||||
|
|
||||||
|
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
setUploadStatus(`Fehler: ${err.error || 'Unbekannter Fehler'}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFiles = async (files: File[]) => {
|
||||||
|
if (files.length === 0) return
|
||||||
|
setUploading(true)
|
||||||
|
setUploadStatus(`Lade ${files.length} Datei(en) hoch...`)
|
||||||
|
|
||||||
|
let success = 0
|
||||||
|
for (const file of files) {
|
||||||
|
const ok = await uploadFile(file)
|
||||||
|
if (ok) success++
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadStatus(`${success} von ${files.length} hochgeladen.`)
|
||||||
|
setUploadCaption('')
|
||||||
|
await loadData()
|
||||||
|
setUploading(false)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
setTimeout(() => setUploadStatus(''), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
handleFiles(Array.from(e.target.files || []))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
handleFiles(Array.from(e.dataTransfer.files))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMedia = async (id: number) => {
|
||||||
|
if (!confirm('Datei wirklich löschen?')) return
|
||||||
|
await fetch(`/api/media/${id}`, { method: 'DELETE' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMemory = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSavingMemory(true)
|
||||||
|
try {
|
||||||
|
if (editingMemory) {
|
||||||
|
await fetch(`/api/memories/${editingMemory.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: editingMemory.title,
|
||||||
|
content: editingMemory.content,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
setEditingMemory(null)
|
||||||
|
} else {
|
||||||
|
await fetch('/api/memories', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newMemory),
|
||||||
|
})
|
||||||
|
setNewMemory({ title: '', content: '' })
|
||||||
|
setShowNewMemory(false)
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
} finally {
|
||||||
|
setSavingMemory(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMemory = async (id: number) => {
|
||||||
|
if (!confirm('Erinnerung wirklich löschen?')) return
|
||||||
|
await fetch(`/api/memories/${id}`, { method: 'DELETE' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading state ──────────────────────────────────────────────────────────
|
||||||
|
if (authed === null) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-cream">
|
||||||
|
<Loader2 className="animate-spin text-warm-gold" size={28} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login ─────────────────────────────────────────────────────────────────
|
||||||
|
if (!authed) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-cream px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h1 className="font-cormorant italic text-4xl text-center text-warm-brown mb-1">
|
||||||
|
Verwaltung
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-warm-brown-light text-sm mb-8 font-lora">
|
||||||
|
Maria Malejka · Gedenkseite
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={login} className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Passwort"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400 font-lora"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{loginError && (
|
||||||
|
<p className="text-red-500 text-sm text-center">{loginError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loginLoading}
|
||||||
|
className="w-full py-3 bg-amber-800 hover:bg-amber-700 disabled:opacity-60 text-amber-100 rounded-xl font-lora transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loginLoading && <Loader2 size={16} className="animate-spin" />}
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="block text-center mt-5 text-sm text-warm-brown-light hover:text-warm-gold transition-colors"
|
||||||
|
>
|
||||||
|
← Zur Gedenkseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin UI ───────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-cream">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-amber-950/95 text-amber-100 px-4 py-3 flex items-center justify-between sticky top-0 z-20 backdrop-blur-sm">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="text-amber-400 hover:text-amber-200 transition-colors flex items-center gap-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
Seite ansehen
|
||||||
|
</a>
|
||||||
|
<h1 className="font-cormorant italic text-xl text-amber-200">
|
||||||
|
Verwaltung
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-amber-500 hover:text-amber-200 transition-colors flex items-center gap-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-10 space-y-14">
|
||||||
|
{/* ── Upload Section ───────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
|
||||||
|
<Upload size={20} className="text-warm-gold" />
|
||||||
|
Dateien hochladen
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uploadCaption}
|
||||||
|
onChange={(e) => setUploadCaption(e.target.value)}
|
||||||
|
placeholder="Bildunterschrift / Beschreibung (optional)"
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-warm-border bg-white text-warm-brown text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 font-lora"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
className={`border-2 border-dashed rounded-2xl p-8 text-center transition-colors ${
|
||||||
|
dragOver
|
||||||
|
? 'border-amber-500 bg-amber-50/50'
|
||||||
|
: 'border-warm-border hover:border-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="mx-auto mb-3 text-warm-gold" size={28} />
|
||||||
|
<p className="text-warm-brown font-lora text-sm mb-1">
|
||||||
|
Fotos, Videos oder Musik hochladen
|
||||||
|
</p>
|
||||||
|
<p className="text-warm-brown-light text-xs mb-4">
|
||||||
|
JPG, PNG, HEIC (iPhone), MP4, MOV, MP3, M4A — mehrere auf einmal möglich
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,video/*,audio/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
id="file-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className={`inline-block px-6 py-2.5 rounded-xl cursor-pointer transition-colors font-lora text-sm ${
|
||||||
|
uploading
|
||||||
|
? 'bg-amber-200 text-amber-700 cursor-not-allowed'
|
||||||
|
: 'bg-amber-800 hover:bg-amber-700 text-amber-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{uploading ? 'Lädt hoch…' : 'Dateien auswählen'}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{uploadStatus && (
|
||||||
|
<p className="mt-3 text-sm text-warm-brown-light font-lora">
|
||||||
|
{uploadStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Photos ──────────────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
|
||||||
|
<ImageIcon size={20} className="text-warm-gold" />
|
||||||
|
Fotos
|
||||||
|
<span className="text-warm-brown-light text-xl">({photos.length})</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{photos.length === 0 ? (
|
||||||
|
<p className="text-warm-brown-light text-sm italic font-lora">
|
||||||
|
Noch keine Fotos hochgeladen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||||
|
{photos.map((photo) => (
|
||||||
|
<div key={photo.id} className="relative group aspect-square">
|
||||||
|
<img
|
||||||
|
src={`/api/files/${photo.filename}`}
|
||||||
|
alt={photo.caption || ''}
|
||||||
|
className="w-full h-full object-cover rounded-xl"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMedia(photo.id)}
|
||||||
|
className="absolute top-1 right-1 bg-red-500/90 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
{photo.caption && (
|
||||||
|
<div className="absolute bottom-0 inset-x-0 bg-black/50 rounded-b-xl px-1.5 py-1">
|
||||||
|
<p className="text-white text-xs truncate">{photo.caption}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Videos ──────────────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
|
||||||
|
<Film size={20} className="text-warm-gold" />
|
||||||
|
Videos
|
||||||
|
<span className="text-warm-brown-light text-xl">({videos.length})</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{videos.length === 0 ? (
|
||||||
|
<p className="text-warm-brown-light text-sm italic font-lora">
|
||||||
|
Noch keine Videos hochgeladen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
{videos.map((video) => (
|
||||||
|
<div key={video.id} className="relative group">
|
||||||
|
<video
|
||||||
|
src={`/api/files/${video.filename}`}
|
||||||
|
className="w-full aspect-video object-cover rounded-xl bg-stone-900"
|
||||||
|
preload="metadata"
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-warm-brown-light mt-1 truncate font-lora">
|
||||||
|
{video.original_name || video.caption || video.filename}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMedia(video.id)}
|
||||||
|
className="absolute top-1 right-1 bg-red-500/90 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Music ───────────────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="font-cormorant italic text-3xl text-warm-brown mb-5 flex items-center gap-2">
|
||||||
|
<Music size={20} className="text-warm-gold" />
|
||||||
|
Musik
|
||||||
|
<span className="text-warm-brown-light text-xl">({music.length})</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{music.length === 0 ? (
|
||||||
|
<p className="text-warm-brown-light text-sm italic font-lora">
|
||||||
|
Noch keine Musik hochgeladen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{music.map((track, i) => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className="flex items-center gap-3 bg-white/60 rounded-xl px-4 py-3 border border-warm-border group"
|
||||||
|
>
|
||||||
|
<span className="text-warm-gold/60 text-sm font-cormorant">{i + 1}.</span>
|
||||||
|
<Music size={14} className="text-warm-gold flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-warm-brown truncate font-lora">
|
||||||
|
{track.original_name?.replace(/\.[^/.]+$/, '') ||
|
||||||
|
track.caption ||
|
||||||
|
track.filename}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMedia(track.id)}
|
||||||
|
className="text-red-400/60 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Memories ────────────────────────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="font-cormorant italic text-3xl text-warm-brown flex items-center gap-2">
|
||||||
|
<FileText size={20} className="text-warm-gold" />
|
||||||
|
Erinnerungen
|
||||||
|
<span className="text-warm-brown-light text-xl">({memories.length})</span>
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewMemory(true)
|
||||||
|
setEditingMemory(null)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-amber-800 hover:bg-amber-700 text-amber-100 rounded-xl text-sm transition-colors font-lora"
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
Neue Erinnerung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New / Edit form */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{(showNewMemory || editingMemory) && (
|
||||||
|
<motion.form
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
onSubmit={saveMemory}
|
||||||
|
className="bg-white/80 rounded-2xl p-5 mb-6 space-y-3 border border-warm-border overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-warm-brown-light font-lora">
|
||||||
|
{editingMemory ? 'Erinnerung bearbeiten' : 'Neue Erinnerung'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewMemory(false)
|
||||||
|
setEditingMemory(null)
|
||||||
|
}}
|
||||||
|
className="text-warm-brown-light hover:text-warm-brown"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingMemory ? editingMemory.title : newMemory.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
editingMemory
|
||||||
|
? setEditingMemory({ ...editingMemory, title: e.target.value })
|
||||||
|
: setNewMemory({ ...newMemory, title: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Titel"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-warm-border bg-white font-cormorant italic text-xl text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={editingMemory ? editingMemory.content : newMemory.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
editingMemory
|
||||||
|
? setEditingMemory({ ...editingMemory, content: e.target.value })
|
||||||
|
: setNewMemory({ ...newMemory, content: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Deine Gedanken und Erinnerungen…"
|
||||||
|
required
|
||||||
|
rows={7}
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white font-lora text-warm-brown focus:outline-none focus:ring-2 focus:ring-amber-400 resize-none text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewMemory(false)
|
||||||
|
setEditingMemory(null)
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-xl border border-warm-border text-warm-brown-light hover:text-warm-brown text-sm transition-colors font-lora"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingMemory}
|
||||||
|
className="px-4 py-2 bg-amber-800 hover:bg-amber-700 disabled:opacity-60 text-amber-100 rounded-xl text-sm transition-colors flex items-center gap-1.5 font-lora"
|
||||||
|
>
|
||||||
|
{savingMemory ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={14} />
|
||||||
|
)}
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{memories.length === 0 ? (
|
||||||
|
<p className="text-warm-brown-light text-sm italic font-lora">
|
||||||
|
Noch keine Erinnerungen geschrieben.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{memories.map((memory) => (
|
||||||
|
<div
|
||||||
|
key={memory.id}
|
||||||
|
className={`bg-white/60 rounded-2xl p-5 border transition-colors ${
|
||||||
|
editingMemory?.id === memory.id
|
||||||
|
? 'border-amber-400'
|
||||||
|
: 'border-warm-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-cormorant italic text-xl text-warm-brown leading-snug">
|
||||||
|
{memory.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-warm-brown-light text-xs mt-0.5 font-lora">
|
||||||
|
{new Date(memory.created_at).toLocaleDateString('de-DE', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-warm-brown/70 text-sm mt-2 line-clamp-3 font-lora leading-relaxed">
|
||||||
|
{memory.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingMemory(memory)
|
||||||
|
setShowNewMemory(false)
|
||||||
|
}}
|
||||||
|
className="p-2 text-amber-700 hover:text-amber-500 transition-colors rounded-lg hover:bg-amber-50"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Edit2 size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMemory(memory.id)}
|
||||||
|
className="p-2 text-red-400/70 hover:text-red-500 transition-colors rounded-lg hover:bg-red-50"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
|
function getExpectedToken() {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const cookieStore = cookies()
|
||||||
|
const token = cookieStore.get('admin_auth')?.value
|
||||||
|
return NextResponse.json({ authed: token === getExpectedToken() })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { password } = await req.json()
|
||||||
|
|
||||||
|
if (password !== (process.env.ADMIN_PASSWORD || 'change-me')) {
|
||||||
|
return NextResponse.json({ error: 'Falsches Passwort' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.json({ success: true })
|
||||||
|
response.cookies.set('admin_auth', getExpectedToken(), {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
const response = NextResponse.json({ success: true })
|
||||||
|
response.cookies.delete('admin_auth')
|
||||||
|
return response
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.heic': 'image/heic',
|
||||||
|
'.heif': 'image/heif',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.m4a': 'audio/mp4',
|
||||||
|
'.aac': 'audio/aac',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { path: string[] } }
|
||||||
|
) {
|
||||||
|
const filePath = path.join(DATA_DIR, 'uploads', ...params.path)
|
||||||
|
|
||||||
|
// Prevent path traversal
|
||||||
|
const uploadsBase = path.join(DATA_DIR, 'uploads')
|
||||||
|
if (!filePath.startsWith(uploadsBase)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
const contentType = MIME[ext] || 'application/octet-stream'
|
||||||
|
const stat = fs.statSync(filePath)
|
||||||
|
|
||||||
|
// Range requests for video/audio seeking
|
||||||
|
const range = request.headers.get('range')
|
||||||
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, '').split('-')
|
||||||
|
const start = parseInt(parts[0], 10)
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1
|
||||||
|
const chunksize = end - start + 1
|
||||||
|
const stream = fs.createReadStream(filePath, { start, end })
|
||||||
|
|
||||||
|
return new NextResponse(stream as unknown as ReadableStream, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': String(chunksize),
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = fs.readFileSync(filePath)
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Length': String(stat.size),
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { unlink } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
function isAdmin() {
|
||||||
|
const token = cookies().get('admin_auth')?.value
|
||||||
|
const expected = createHash('sha256')
|
||||||
|
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||||
|
.digest('hex')
|
||||||
|
return token === expected
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const media = db.prepare('SELECT * FROM media WHERE id = ?').get(params.id) as
|
||||||
|
| { filename: string }
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
const filePath = path.join(DATA_DIR, 'uploads', media.filename)
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
await unlink(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM media WHERE id = ?').run(params.id)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const type = req.nextUrl.searchParams.get('type')
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const query = type
|
||||||
|
? 'SELECT * FROM media WHERE type = ? ORDER BY sort_order, created_at'
|
||||||
|
: 'SELECT * FROM media ORDER BY sort_order, created_at'
|
||||||
|
|
||||||
|
const media = type
|
||||||
|
? db.prepare(query).all(type)
|
||||||
|
: db.prepare(query).all()
|
||||||
|
|
||||||
|
return NextResponse.json(media)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
function isAdmin() {
|
||||||
|
const token = cookies().get('admin_auth')?.value
|
||||||
|
const expected = createHash('sha256')
|
||||||
|
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||||
|
.digest('hex')
|
||||||
|
return token === expected
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, content } = await req.json()
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE memories SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?"
|
||||||
|
).run(title, content, params.id)
|
||||||
|
const memory = db.prepare('SELECT * FROM memories WHERE id = ?').get(params.id)
|
||||||
|
return NextResponse.json(memory)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('DELETE FROM memories WHERE id = ?').run(params.id)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
function isAdmin() {
|
||||||
|
const token = cookies().get('admin_auth')?.value
|
||||||
|
const expected = createHash('sha256')
|
||||||
|
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||||
|
.digest('hex')
|
||||||
|
return token === expected
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb()
|
||||||
|
const memories = db
|
||||||
|
.prepare('SELECT * FROM memories ORDER BY created_at DESC')
|
||||||
|
.all()
|
||||||
|
return NextResponse.json(memories)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, content } = await req.json()
|
||||||
|
if (!title?.trim() || !content?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Titel und Inhalt sind erforderlich' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare('INSERT INTO memories (title, content) VALUES (?, ?)')
|
||||||
|
.run(title.trim(), content.trim())
|
||||||
|
const memory = db
|
||||||
|
.prepare('SELECT * FROM memories WHERE id = ?')
|
||||||
|
.get(result.lastInsertRowid)
|
||||||
|
return NextResponse.json(memory, { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { writeFile, mkdir } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { createHash, randomUUID } from 'crypto'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const maxDuration = 60
|
||||||
|
|
||||||
|
function isAdmin() {
|
||||||
|
const token = cookies().get('admin_auth')?.value
|
||||||
|
const expected = createHash('sha256')
|
||||||
|
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||||
|
.digest('hex')
|
||||||
|
return token === expected
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||||
|
|
||||||
|
const MIME_TO_FOLDER: Record<string, string> = {
|
||||||
|
'image/jpeg': 'photos',
|
||||||
|
'image/jpg': 'photos',
|
||||||
|
'image/png': 'photos',
|
||||||
|
'image/webp': 'photos',
|
||||||
|
'image/gif': 'photos',
|
||||||
|
'image/heic': 'photos',
|
||||||
|
'image/heif': 'photos',
|
||||||
|
'video/mp4': 'videos',
|
||||||
|
'video/quicktime': 'videos',
|
||||||
|
'video/mov': 'videos',
|
||||||
|
'video/x-msvideo': 'videos',
|
||||||
|
'audio/mpeg': 'music',
|
||||||
|
'audio/mp3': 'music',
|
||||||
|
'audio/mp4': 'music',
|
||||||
|
'audio/m4a': 'music',
|
||||||
|
'audio/aac': 'music',
|
||||||
|
'audio/wav': 'music',
|
||||||
|
'audio/x-wav': 'music',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOLDER_TO_TYPE: Record<string, 'photo' | 'video' | 'music'> = {
|
||||||
|
photos: 'photo',
|
||||||
|
videos: 'video',
|
||||||
|
music: 'music',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get('file') as File | null
|
||||||
|
const caption = formData.get('caption') as string | null
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Keine Datei' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect type from mime or extension
|
||||||
|
let mimeType = file.type?.toLowerCase() || ''
|
||||||
|
const ext = path.extname(file.name).toLowerCase()
|
||||||
|
|
||||||
|
// iOS HEIC fallback
|
||||||
|
if (!mimeType && (ext === '.heic' || ext === '.heif')) {
|
||||||
|
mimeType = 'image/heic'
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = MIME_TO_FOLDER[mimeType]
|
||||||
|
if (!folder) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Dateityp "${mimeType}" nicht unterstützt` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${folder}/${randomUUID()}${ext || '.bin'}`
|
||||||
|
const filePath = path.join(DATA_DIR, 'uploads', filename)
|
||||||
|
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true })
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
await writeFile(filePath, buffer)
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO media (filename, original_name, type, caption) VALUES (?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(filename, file.name, FOLDER_TO_TYPE[folder], caption || null)
|
||||||
|
|
||||||
|
const media = db
|
||||||
|
.prepare('SELECT * FROM media WHERE id = ?')
|
||||||
|
.get(result.lastInsertRowid)
|
||||||
|
return NextResponse.json(media, { status: 201 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #FAF7F0;
|
||||||
|
color: #3D2B1F;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: #E2C97E;
|
||||||
|
color: #3D2B1F;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #FAF7F0;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #C4A04A55;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #C4A04A;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Cormorant_Garamond, Lora } from 'next/font/google'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
const cormorant = Cormorant_Garamond({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['300', '400', '500', '600', '700'],
|
||||||
|
style: ['normal', 'italic'],
|
||||||
|
variable: '--font-cormorant',
|
||||||
|
display: 'swap',
|
||||||
|
})
|
||||||
|
|
||||||
|
const lora = Lora({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['400', '500', '600', '700'],
|
||||||
|
style: ['normal', 'italic'],
|
||||||
|
variable: '--font-lora',
|
||||||
|
display: 'swap',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'In Erinnerung an Maria Malejka',
|
||||||
|
description:
|
||||||
|
'Eine liebevolle Gedenkseite für Maria Malejka · 29. November 1944 – 10. Februar 2026',
|
||||||
|
openGraph: {
|
||||||
|
title: 'In Erinnerung an Maria Malejka',
|
||||||
|
description: '29. November 1944 – 10. Februar 2026',
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="de" className={`${cormorant.variable} ${lora.variable}`}>
|
||||||
|
<body className="font-lora antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import type { Memory, MediaItem } from '@/lib/types'
|
||||||
|
import HeroSection from '@/components/HeroSection'
|
||||||
|
import PhotoSlideshow from '@/components/PhotoSlideshow'
|
||||||
|
import PhotoGallery from '@/components/PhotoGallery'
|
||||||
|
import MemorySection from '@/components/MemorySection'
|
||||||
|
import MusicPlayer from '@/components/MusicPlayer'
|
||||||
|
import VideoGallery from '@/components/VideoGallery'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const photos = db
|
||||||
|
.prepare("SELECT * FROM media WHERE type = 'photo' ORDER BY sort_order, created_at")
|
||||||
|
.all() as MediaItem[]
|
||||||
|
const videos = db
|
||||||
|
.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at")
|
||||||
|
.all() as MediaItem[]
|
||||||
|
const music = db
|
||||||
|
.prepare("SELECT * FROM media WHERE type = 'music' ORDER BY sort_order, created_at")
|
||||||
|
.all() as MediaItem[]
|
||||||
|
const memories = db
|
||||||
|
.prepare('SELECT * FROM memories ORDER BY created_at DESC')
|
||||||
|
.all() as Memory[]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-cream">
|
||||||
|
{/* Hero */}
|
||||||
|
<HeroSection heroPhoto={photos[0]?.filename ?? null} />
|
||||||
|
|
||||||
|
{/* Navigation anchors */}
|
||||||
|
<nav className="sticky top-0 z-20 bg-cream/80 backdrop-blur-sm border-b border-warm-border">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-center gap-6 sm:gap-10">
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<a
|
||||||
|
href="#bilder"
|
||||||
|
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
||||||
|
>
|
||||||
|
Bilder
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{memories.length > 0 && (
|
||||||
|
<a
|
||||||
|
href="#erinnerungen"
|
||||||
|
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
||||||
|
>
|
||||||
|
Erinnerungen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{videos.length > 0 && (
|
||||||
|
<a
|
||||||
|
href="#videos"
|
||||||
|
className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors"
|
||||||
|
>
|
||||||
|
Videos
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Photo section */}
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<section id="bilder" className="py-16 sm:py-20">
|
||||||
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||||
|
Erinnerungen in Bildern
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<div className="h-px w-16 bg-warm-gold/40" />
|
||||||
|
<span className="text-warm-gold text-xl">✦</span>
|
||||||
|
<div className="h-px w-16 bg-warm-gold/40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{photos.length > 1 && <PhotoSlideshow photos={photos} />}
|
||||||
|
<PhotoGallery photos={photos} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Memories */}
|
||||||
|
<MemorySection memories={memories} />
|
||||||
|
|
||||||
|
{/* Videos */}
|
||||||
|
<VideoGallery videos={videos} />
|
||||||
|
|
||||||
|
{/* Music player */}
|
||||||
|
<MusicPlayer tracks={music} />
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-16 text-center border-t border-warm-border bg-amber-50/30">
|
||||||
|
<div className="max-w-lg mx-auto px-4">
|
||||||
|
<p className="font-cormorant italic text-3xl text-warm-gold mb-3">
|
||||||
|
In liebevoller Erinnerung
|
||||||
|
</p>
|
||||||
|
<p className="font-lora text-warm-brown-light text-sm tracking-wider">
|
||||||
|
Maria Malejka
|
||||||
|
</p>
|
||||||
|
<p className="font-lora text-warm-brown-light/60 text-xs mt-1 tracking-widest">
|
||||||
|
29. November 1944 — 10. Februar 2026
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Ornament */}
|
||||||
|
<div className="flex items-center justify-center gap-3 mt-6 mb-6">
|
||||||
|
<div className="h-px w-12 bg-warm-gold/20" />
|
||||||
|
<span className="text-warm-gold/40 text-sm">✦</span>
|
||||||
|
<div className="h-px w-12 bg-warm-gold/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-cormorant italic text-warm-brown-light/60 text-lg">
|
||||||
|
„Du bist nicht fort, nur ein Schritt voraus."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden admin link */}
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="mt-10 inline-block text-warm-border/30 hover:text-warm-border/60 text-xs transition-colors"
|
||||||
|
title="Verwaltung"
|
||||||
|
>
|
||||||
|
·
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
|
interface HeroSectionProps {
|
||||||
|
heroPhoto: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroSection({ heroPhoto }: HeroSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden">
|
||||||
|
{/* Background */}
|
||||||
|
{heroPhoto ? (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<img
|
||||||
|
src={`/api/files/${heroPhoto}`}
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
<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"
|
||||||
|
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")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
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">
|
||||||
|
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)' }}
|
||||||
|
>
|
||||||
|
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)' }}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
|
||||||
|
<p className="font-lora text-amber-100/80 text-base sm:text-lg tracking-[0.2em]">
|
||||||
|
29. November 1944
|
||||||
|
</p>
|
||||||
|
<p className="font-lora text-amber-100/50 text-sm tracking-[0.15em] mt-1">
|
||||||
|
— 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"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1.2, duration: 2 }}
|
||||||
|
>
|
||||||
|
„Wer im Herzen der Menschen weiterlebt,
|
||||||
|
<br />
|
||||||
|
der ist nicht wirklich fort."
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
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>
|
||||||
|
<ChevronDown size={20} />
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { Memory } from '@/lib/types'
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MemorySection({ memories }: { memories: Memory[] }) {
|
||||||
|
if (memories.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="erinnerungen" className="py-20 bg-amber-50/60">
|
||||||
|
<div className="max-w-3xl mx-auto px-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-center mb-14"
|
||||||
|
>
|
||||||
|
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||||
|
Gedanken & Erinnerungen
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<div className="h-px w-16 bg-warm-gold/40" />
|
||||||
|
<span className="text-warm-gold text-xl">✦</span>
|
||||||
|
<div className="h-px w-16 bg-warm-gold/40" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="space-y-10">
|
||||||
|
{memories.map((memory, i) => (
|
||||||
|
<motion.article
|
||||||
|
key={memory.id}
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '-40px' }}
|
||||||
|
transition={{ delay: Math.min(i * 0.1, 0.3), duration: 0.7 }}
|
||||||
|
className="relative bg-white/80 backdrop-blur-sm rounded-2xl p-8 sm:p-10 shadow-sm border border-warm-border"
|
||||||
|
>
|
||||||
|
{/* Opening quote mark */}
|
||||||
|
<span
|
||||||
|
className="absolute -top-4 left-8 font-cormorant text-5xl text-warm-gold/40 leading-none select-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
❝
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h3 className="font-cormorant italic text-2xl sm:text-3xl text-warm-brown mb-5 leading-snug">
|
||||||
|
{memory.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="font-lora text-warm-brown/80 text-base sm:text-lg leading-relaxed whitespace-pre-wrap">
|
||||||
|
{memory.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-warm-brown-light font-lora">
|
||||||
|
{formatDate(memory.created_at)}
|
||||||
|
</p>
|
||||||
|
</motion.article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
SkipForward,
|
||||||
|
SkipBack,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Music,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { MediaItem } from '@/lib/types'
|
||||||
|
|
||||||
|
export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||||
|
const [current, setCurrent] = useState(0)
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [volume, setVolume] = useState(0.35)
|
||||||
|
const [muted, setMuted] = useState(false)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
|
const track = tracks[current]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
audio.volume = muted ? 0 : volume
|
||||||
|
}, [volume, muted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
audio.src = `/api/files/${track.filename}`
|
||||||
|
audio.volume = muted ? 0 : volume
|
||||||
|
if (playing) {
|
||||||
|
audio.play().catch(() => setPlaying(false))
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [current])
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
if (playing) {
|
||||||
|
audio.pause()
|
||||||
|
setPlaying(false)
|
||||||
|
} else {
|
||||||
|
audio.play().catch(() => setPlaying(false))
|
||||||
|
setPlaying(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = () => setCurrent((c) => (c - 1 + tracks.length) % tracks.length)
|
||||||
|
const next = () => setCurrent((c) => (c + 1) % tracks.length)
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || !audio.duration) return
|
||||||
|
setProgress((audio.currentTime / audio.duration) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || !audio.duration) return
|
||||||
|
const pct = parseFloat(e.target.value)
|
||||||
|
audio.currentTime = (pct / 100) * audio.duration
|
||||||
|
setProgress(pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackName =
|
||||||
|
track.original_name?.replace(/\.[^/.]+$/, '') ||
|
||||||
|
track.caption ||
|
||||||
|
`Titel ${current + 1}`
|
||||||
|
|
||||||
|
if (tracks.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={`/api/files/${track.filename}`}
|
||||||
|
onEnded={next}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating button */}
|
||||||
|
<motion.button
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className={`fixed bottom-6 right-6 z-40 rounded-full p-3.5 shadow-lg transition-colors backdrop-blur-sm ${
|
||||||
|
playing
|
||||||
|
? 'bg-amber-700/95 text-amber-100 ring-2 ring-amber-400/40'
|
||||||
|
: 'bg-stone-800/90 text-amber-200/80 hover:bg-amber-900/90'
|
||||||
|
}`}
|
||||||
|
aria-label="Musik"
|
||||||
|
>
|
||||||
|
<Music size={20} />
|
||||||
|
{playing && (
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* Player panel */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed bottom-20 right-6 z-40 bg-stone-950/95 backdrop-blur-md rounded-2xl p-5 shadow-2xl w-72 border border-amber-900/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-amber-200 font-cormorant italic text-base leading-snug truncate">
|
||||||
|
{trackName}
|
||||||
|
</p>
|
||||||
|
<p className="text-amber-600 text-xs mt-0.5">
|
||||||
|
{current + 1} / {tracks.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisible(false)}
|
||||||
|
className="text-amber-800 hover:text-amber-500 ml-2 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
value={progress}
|
||||||
|
onChange={handleSeek}
|
||||||
|
className="w-full mb-4 accent-amber-500 h-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-5 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={prev}
|
||||||
|
className="text-amber-500 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
<SkipBack size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="bg-amber-700 hover:bg-amber-600 text-white rounded-full w-10 h-10 flex items-center justify-center transition-colors shadow-lg"
|
||||||
|
>
|
||||||
|
{playing ? <Pause size={18} /> : <Play size={18} className="ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={next}
|
||||||
|
className="text-amber-500 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
<SkipForward size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMuted((m) => !m)}
|
||||||
|
className="text-amber-600 hover:text-amber-400 transition-colors"
|
||||||
|
>
|
||||||
|
{muted ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={muted ? 0 : volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVolume(parseFloat(e.target.value))
|
||||||
|
setMuted(false)
|
||||||
|
}}
|
||||||
|
className="flex-1 accent-amber-600 h-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import type { MediaItem } from '@/lib/types'
|
||||||
|
|
||||||
|
export default function PhotoGallery({ photos }: { photos: MediaItem[] }) {
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const openLightbox = (i: number) => setLightboxIndex(i)
|
||||||
|
const closeLightbox = () => setLightboxIndex(null)
|
||||||
|
const prevPhoto = () =>
|
||||||
|
setLightboxIndex((i) => (i! - 1 + photos.length) % photos.length)
|
||||||
|
const nextPhoto = () =>
|
||||||
|
setLightboxIndex((i) => (i! + 1) % photos.length)
|
||||||
|
|
||||||
|
if (photos.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
{photos.map((photo, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={photo.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.92 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true, margin: '-30px' }}
|
||||||
|
transition={{ delay: (i % 8) * 0.06, duration: 0.5 }}
|
||||||
|
onClick={() => openLightbox(i)}
|
||||||
|
className="relative aspect-square overflow-hidden rounded-xl shadow-md hover:shadow-xl transition-shadow duration-300 group"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/files/${photo.filename}`}
|
||||||
|
alt={photo.caption || 'Maria Malejka'}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{photo.caption && (
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end">
|
||||||
|
<p className="text-white text-xs p-3 font-cormorant italic leading-snug">
|
||||||
|
{photo.caption}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{lightboxIndex !== null && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||||
|
onClick={closeLightbox}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={closeLightbox}
|
||||||
|
className="absolute top-4 right-4 text-white/70 hover:text-white z-10 bg-black/30 rounded-full p-2"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); prevPhoto() }}
|
||||||
|
className="absolute left-4 text-white/70 hover:text-white z-10 bg-black/30 rounded-full p-3"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={28} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); nextPhoto() }}
|
||||||
|
className="absolute right-4 text-white/70 hover:text-white z-10 bg-black/30 rounded-full p-3"
|
||||||
|
>
|
||||||
|
<ChevronRight size={28} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
key={lightboxIndex}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className="relative max-w-5xl max-h-[88vh] w-full px-16 flex flex-col items-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/files/${photos[lightboxIndex].filename}`}
|
||||||
|
alt={photos[lightboxIndex].caption || ''}
|
||||||
|
className="max-h-[78vh] max-w-full object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
{photos[lightboxIndex].caption && (
|
||||||
|
<p className="text-center text-amber-200 font-cormorant italic text-xl mt-4">
|
||||||
|
{photos[lightboxIndex].caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-white/30 text-xs mt-2">
|
||||||
|
{lightboxIndex + 1} / {photos.length}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import type { MediaItem } from '@/lib/types'
|
||||||
|
|
||||||
|
export default function PhotoSlideshow({ photos }: { photos: MediaItem[] }) {
|
||||||
|
const [current, setCurrent] = useState(0)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
|
||||||
|
const next = useCallback(
|
||||||
|
() => setCurrent((c) => (c + 1) % photos.length),
|
||||||
|
[photos.length]
|
||||||
|
)
|
||||||
|
const prev = useCallback(
|
||||||
|
() => setCurrent((c) => (c - 1 + photos.length) % photos.length),
|
||||||
|
[photos.length]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || photos.length <= 1) return
|
||||||
|
const id = setInterval(next, 5500)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [paused, photos.length, next])
|
||||||
|
|
||||||
|
if (photos.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative w-full overflow-hidden rounded-2xl shadow-2xl mb-10"
|
||||||
|
style={{ aspectRatio: '16/7' }}
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={photos[current].id}
|
||||||
|
className="absolute inset-0"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 1.4, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/files/${photos[current].filename}`}
|
||||||
|
alt={photos[current].caption || 'Maria Malejka'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||||
|
|
||||||
|
{photos[current].caption && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="absolute bottom-0 inset-x-0 p-6 text-center"
|
||||||
|
>
|
||||||
|
<p className="text-white font-cormorant italic text-lg drop-shadow">
|
||||||
|
{photos[current].caption}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Navigation arrows */}
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={prev}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/30 hover:bg-black/50 text-white rounded-full p-2 transition-colors z-10 backdrop-blur-sm"
|
||||||
|
aria-label="Vorheriges Bild"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={next}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/30 hover:bg-black/50 text-white rounded-full p-2 transition-colors z-10 backdrop-blur-sm"
|
||||||
|
aria-label="Nächstes Bild"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{photos.length > 1 && (
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-10">
|
||||||
|
{photos.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrent(i)}
|
||||||
|
className={`rounded-full transition-all duration-500 ${
|
||||||
|
i === current
|
||||||
|
? 'bg-amber-300 w-6 h-2'
|
||||||
|
: 'bg-white/50 w-2 h-2 hover:bg-white/80'
|
||||||
|
}`}
|
||||||
|
aria-label={`Bild ${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Play } from 'lucide-react'
|
||||||
|
import type { MediaItem } from '@/lib/types'
|
||||||
|
|
||||||
|
export default function VideoGallery({ videos }: { videos: MediaItem[] }) {
|
||||||
|
const [playing, setPlaying] = useState<number | null>(null)
|
||||||
|
|
||||||
|
if (videos.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="videos" className="py-20">
|
||||||
|
<div className="max-w-5xl mx-auto px-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-center mb-14"
|
||||||
|
>
|
||||||
|
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||||
|
Videoerinnerungen
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<div className="h-px w-16 bg-warm-gold/40" />
|
||||||
|
<span className="text-warm-gold text-xl">❧</span>
|
||||||
|
<div className="h-px w-16 bg-warm-gold/40" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
{videos.map((video, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={video.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '-30px' }}
|
||||||
|
transition={{ delay: i * 0.1 }}
|
||||||
|
className="rounded-2xl overflow-hidden shadow-lg bg-stone-900"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video bg-stone-950">
|
||||||
|
{playing === video.id ? (
|
||||||
|
<video
|
||||||
|
src={`/api/files/${video.filename}`}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onEnded={() => setPlaying(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setPlaying(video.id)}
|
||||||
|
className="w-full h-full flex items-center justify-center group relative"
|
||||||
|
>
|
||||||
|
{/* Blurred thumbnail */}
|
||||||
|
<video
|
||||||
|
src={`/api/files/${video.filename}`}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-40"
|
||||||
|
preload="metadata"
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 w-16 h-16 rounded-full bg-amber-700/80 group-hover:bg-amber-600/90 flex items-center justify-center transition-all duration-300 group-hover:scale-105 shadow-xl">
|
||||||
|
<Play size={26} className="text-white ml-1" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{video.caption && (
|
||||||
|
<p className="px-4 py-3 text-amber-200/90 font-cormorant italic text-center text-sm">
|
||||||
|
{video.caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
export type { Memory, MediaItem } from './types'
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||||
|
|
||||||
|
// Ensure upload directories exist
|
||||||
|
for (const dir of ['uploads/photos', 'uploads/videos', 'uploads/music']) {
|
||||||
|
fs.mkdirSync(path.join(DATA_DIR, dir), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = path.join(DATA_DIR, 'oma.db')
|
||||||
|
|
||||||
|
let _db: DatabaseSync | null = null
|
||||||
|
|
||||||
|
export function getDb(): DatabaseSync {
|
||||||
|
if (!_db) {
|
||||||
|
_db = new DatabaseSync(dbPath)
|
||||||
|
initDb(_db)
|
||||||
|
}
|
||||||
|
return _db
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDb(db: DatabaseSync) {
|
||||||
|
db.exec(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
original_name TEXT,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('photo', 'video', 'music')),
|
||||||
|
caption TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type Memory = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaItem = {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
original_name: string | null
|
||||||
|
type: 'photo' | 'video' | 'music'
|
||||||
|
caption: string | null
|
||||||
|
sort_order: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
cream: '#FAF7F0',
|
||||||
|
'warm-brown': '#3D2B1F',
|
||||||
|
'warm-brown-light': '#7C6352',
|
||||||
|
'warm-gold': '#C4A04A',
|
||||||
|
'warm-gold-light': '#E2C97E',
|
||||||
|
'warm-border': '#E8DDD0',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
cormorant: ['var(--font-cormorant)', 'Georgia', 'serif'],
|
||||||
|
lora: ['var(--font-lora)', 'Georgia', 'serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 1.5s ease-out forwards',
|
||||||
|
'float': 'float 3s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
float: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
|
'50%': { transform: 'translateY(-8px)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user