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:
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
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 path from 'path'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import { createHash, randomUUID } from 'crypto'
|
import { createHash, randomUUID } from 'crypto'
|
||||||
@@ -19,6 +19,35 @@ async function isAdmin() {
|
|||||||
|
|
||||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
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> = {
|
const MIME_TO_FOLDER: Record<string, string> = {
|
||||||
'image/jpeg': 'photos',
|
'image/jpeg': 'photos',
|
||||||
'image/jpg': 'photos',
|
'image/jpg': 'photos',
|
||||||
@@ -75,11 +104,22 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const filename = `${folder}/${randomUUID()}${ext || '.bin'}`
|
const filename = `${folder}/${randomUUID()}${ext || '.bin'}`
|
||||||
const filePath = path.join(DATA_DIR, 'uploads', filename)
|
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 })
|
await mkdir(path.dirname(filePath), { recursive: true })
|
||||||
const buffer = Buffer.from(await file.arrayBuffer())
|
|
||||||
await writeFile(filePath, buffer)
|
await writeFile(filePath, buffer)
|
||||||
|
|
||||||
|
// Add to hash cache
|
||||||
|
const hash = await getFileHash(buffer)
|
||||||
|
hashCache?.set(hash, filename)
|
||||||
|
|
||||||
uploadedFiles.push(filename)
|
uploadedFiles.push(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -41,7 +41,7 @@ export default async function HomePage() {
|
|||||||
try {
|
try {
|
||||||
userMemories = plain(
|
userMemories = plain(
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
SELECT id, name, title, content, created_at
|
SELECT id, name, title, content, media_filenames, created_at
|
||||||
FROM contributions
|
FROM contributions
|
||||||
WHERE status = 'approved' AND type = 'memory'
|
WHERE status = 'approved' AND type = 'memory'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -58,6 +58,8 @@ export default async function HomePage() {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
title: m.title || 'Erinnerung',
|
title: m.title || 'Erinnerung',
|
||||||
content: m.content,
|
content: m.content,
|
||||||
|
author: m.name || null,
|
||||||
|
media_filenames: m.media_filenames || null,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
updated_at: m.created_at,
|
updated_at: m.created_at,
|
||||||
}))
|
}))
|
||||||
@@ -144,7 +146,7 @@ export default async function HomePage() {
|
|||||||
filename: filename.trim(),
|
filename: filename.trim(),
|
||||||
original_name: null,
|
original_name: null,
|
||||||
type: 'photo' as const,
|
type: 'photo' as const,
|
||||||
caption: `Von ${c.name || 'Anonym'}`,
|
caption: null,
|
||||||
sort_order: 9998,
|
sort_order: 9998,
|
||||||
status: 'approved' as const,
|
status: 'approved' as const,
|
||||||
created_at: c.created_at,
|
created_at: c.created_at,
|
||||||
|
|||||||
@@ -34,39 +34,56 @@ export default function MemorySection({ memories }: { memories: Memory[] }) {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{memories.map((memory, i) => (
|
{memories.map((memory, i) => {
|
||||||
<motion.article
|
const photos = memory.media_filenames ? memory.media_filenames.split(',').filter(Boolean) : []
|
||||||
key={memory.id}
|
return (
|
||||||
initial={{ opacity: 0, y: 24 }}
|
<motion.article
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
key={memory.id}
|
||||||
viewport={{ once: true, margin: '-40px' }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
transition={{ delay: Math.min(i * 0.1, 0.3), duration: 0.7 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
className="relative bg-white/80 backdrop-blur-sm rounded-2xl p-8 sm:p-10 shadow-sm border border-warm-border"
|
viewport={{ once: true, margin: '-40px' }}
|
||||||
>
|
transition={{ delay: Math.min(i * 0.1, 0.3), duration: 0.7 }}
|
||||||
{/* Opening quote mark */}
|
className="relative bg-white/80 backdrop-blur-sm rounded-2xl p-8 sm:p-10 shadow-sm border border-warm-border"
|
||||||
<span
|
|
||||||
className="absolute -top-4 left-8 font-cormorant text-5xl text-warm-gold/40 leading-none select-none"
|
|
||||||
aria-hidden
|
|
||||||
>
|
>
|
||||||
❝
|
{/* Opening quote mark */}
|
||||||
</span>
|
<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">
|
<h3 className="font-cormorant italic text-2xl sm:text-3xl text-warm-brown mb-5 leading-snug">
|
||||||
{memory.title}
|
{memory.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="font-lora text-warm-brown/80 text-base sm:text-lg leading-relaxed whitespace-pre-wrap">
|
{/* Photos */}
|
||||||
{memory.content}
|
{photos.length > 0 && (
|
||||||
</p>
|
<div className={`grid gap-3 mb-5 ${photos.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||||
|
{photos.map((filename, j) => (
|
||||||
<p className="mt-6 text-xs text-warm-brown-light font-lora">
|
<img
|
||||||
{formatDate(memory.created_at)}
|
key={j}
|
||||||
{memory.author && (
|
src={`/api/files/${filename.trim()}`}
|
||||||
<span className="ml-2 text-warm-brown-light/60">— {memory.author}</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
|
|||||||
key={i}
|
key={i}
|
||||||
src={`/api/files/${filename.trim()}`}
|
src={`/api/files/${filename.trim()}`}
|
||||||
alt=""
|
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>
|
</div>
|
||||||
@@ -310,7 +310,7 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
|
|||||||
key={i}
|
key={i}
|
||||||
src={`/api/files/${filename.trim()}`}
|
src={`/api/files/${filename.trim()}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-48 object-cover rounded-lg"
|
className="w-full object-contain rounded-lg bg-warm-brown/5"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type Memory = {
|
|||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
author: string | null
|
author: string | null
|
||||||
|
media_filenames: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user