fix: photo display, duplicate detection, memory photos

- Remove duplicate FamilyUploadSection from public page
- Remove 'Von Anonym' caption from user-uploaded gallery photos
- Add SHA-256 duplicate detection in upload route (same file → same path)
- Fix timeline photos: use object-contain instead of object-cover (no clipping)
- Fix timeline modal photos: remove fixed h-48 height
- Add photo display support to MemorySection component
- Include media_filenames in memory contribution queries
- Add media_filenames to Memory type

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
denshooter
2026-02-18 12:53:25 +01:00
parent 40ace3522c
commit 9223a2bfbb
5 changed files with 95 additions and 35 deletions
+42 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import { writeFile, mkdir, readdir, readFile } from 'fs/promises'
import path from 'path'
import { cookies } from 'next/headers'
import { createHash, randomUUID } from 'crypto'
@@ -19,6 +19,35 @@ async function isAdmin() {
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
// In-memory hash cache (populated on first use)
let hashCache: Map<string, string> | null = null
async function getFileHash(buffer: Buffer): Promise<string> {
return createHash('sha256').update(buffer).digest('hex')
}
async function buildHashCache(folder: string): Promise<void> {
if (hashCache) return
hashCache = new Map()
const uploadsDir = path.join(DATA_DIR, 'uploads', folder)
try {
const files = await readdir(uploadsDir)
for (const file of files) {
try {
const content = await readFile(path.join(uploadsDir, file))
const hash = await getFileHash(content)
hashCache.set(hash, `${folder}/${file}`)
} catch {}
}
} catch {}
}
async function findDuplicate(buffer: Buffer, folder: string): Promise<string | null> {
await buildHashCache(folder)
const hash = await getFileHash(buffer)
return hashCache?.get(hash) || null
}
const MIME_TO_FOLDER: Record<string, string> = {
'image/jpeg': 'photos',
'image/jpg': 'photos',
@@ -75,11 +104,22 @@ export async function POST(req: NextRequest) {
const filename = `${folder}/${randomUUID()}${ext || '.bin'}`
const filePath = path.join(DATA_DIR, 'uploads', filename)
const buffer = Buffer.from(await file.arrayBuffer())
// Check for duplicate
const existingFile = await findDuplicate(buffer, folder)
if (existingFile) {
uploadedFiles.push(existingFile)
continue
}
await mkdir(path.dirname(filePath), { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())
await writeFile(filePath, buffer)
// Add to hash cache
const hash = await getFileHash(buffer)
hashCache?.set(hash, filename)
uploadedFiles.push(filename)
}
+4 -2
View File
@@ -41,7 +41,7 @@ export default async function HomePage() {
try {
userMemories = plain(
db.prepare(`
SELECT id, name, title, content, created_at
SELECT id, name, title, content, media_filenames, created_at
FROM contributions
WHERE status = 'approved' AND type = 'memory'
ORDER BY created_at DESC
@@ -58,6 +58,8 @@ export default async function HomePage() {
id: m.id,
title: m.title || 'Erinnerung',
content: m.content,
author: m.name || null,
media_filenames: m.media_filenames || null,
created_at: m.created_at,
updated_at: m.created_at,
}))
@@ -144,7 +146,7 @@ export default async function HomePage() {
filename: filename.trim(),
original_name: null,
type: 'photo' as const,
caption: `Von ${c.name || 'Anonym'}`,
caption: null,
sort_order: 9998,
status: 'approved' as const,
created_at: c.created_at,
+46 -29
View File
@@ -34,39 +34,56 @@ export default function MemorySection({ memories }: { memories: Memory[] }) {
</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
{memories.map((memory, i) => {
const photos = memory.media_filenames ? memory.media_filenames.split(',').filter(Boolean) : []
return (
<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"
>
</span>
{/* 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>
<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)}
{memory.author && (
<span className="ml-2 text-warm-brown-light/60"> {memory.author}</span>
{/* Photos */}
{photos.length > 0 && (
<div className={`grid gap-3 mb-5 ${photos.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
{photos.map((filename, j) => (
<img
key={j}
src={`/api/files/${filename.trim()}`}
alt=""
className="w-full object-contain rounded-xl bg-warm-brown/5 max-h-64"
/>
))}
</div>
)}
</p>
</motion.article>
))}
<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)}
{memory.author && (
<span className="ml-2 text-warm-brown-light/60"> {memory.author}</span>
)}
</p>
</motion.article>
)
})}
</div>
</div>
</section>
+2 -2
View File
@@ -197,7 +197,7 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
key={i}
src={`/api/files/${filename.trim()}`}
alt=""
className="w-full h-24 object-cover rounded-lg"
className="w-full max-h-40 object-contain rounded-lg bg-warm-brown/5"
/>
))}
</div>
@@ -310,7 +310,7 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
key={i}
src={`/api/files/${filename.trim()}`}
alt=""
className="w-full h-48 object-cover rounded-lg"
className="w-full object-contain rounded-lg bg-warm-brown/5"
/>
))}
</div>
+1
View File
@@ -3,6 +3,7 @@ export type Memory = {
title: string
content: string
author: string | null
media_filenames: string | null
created_at: string
updated_at: string
}