Compare commits

...

10 Commits

Author SHA1 Message Date
denshooter e3d4f7c96e Fix bugs and improve code quality
Build and Deploy / build-and-deploy (push) Has been cancelled
- Fix memory leak: revoke object URLs in TimelineUploadSection
- Fix broken timeline photo URLs in admin panel (/data/... → /api/files/...)
- Remove duplicate bad-word list in AI moderation function
- Add input validation for type/status params in media and contributions API
- Add bulk-approve button in admin for pending contributions
- Add PATCH endpoint for bulk-approving all pending contributions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 01:02:20 +01:00
denshooter 31dff10636 Use heic-convert for HEIC-to-JPEG conversion
Sharp's prebuilt libvips lacks HEIF codec support. Replace with
heic-convert (pure JS decoder) for reliable HEIC conversion on all
platforms. Existing HEIC files on disk will be converted on-the-fly
when served via /api/files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-22 02:01:04 +01:00
denshooter 6f826c66ea Fix HEIC photos not displaying on non-Apple devices
- Add HEIC-to-JPEG conversion in family-upload route (was missing, unlike admin upload)
- Add error logging for HEIC conversion failures in file serving route

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-22 01:56:37 +01:00
denshooter aa23fb12a5 Refactor Dockerfile: Remove custom libvips build and update stages for Next.js app
Add .dockerignore: Exclude node_modules, .next, .git, markdown files, and docker-compose.yml
2026-02-22 01:50:50 +01:00
denshooter 5061b9c287 Fix Docker build: Use git archive --remote for libvips source
- Replaced curl download with git archive --remote to fetch libvips source, bypassing previous download issues.
- Adjusted tar extraction and directory navigation to correctly handle the output of git archive.
2026-02-22 01:45:47 +01:00
denshooter a6b211a749 Fix Docker build: Download libvips tarball to temporary file first
- Modified the Dockerfile to download the libvips tarball to a temporary file (vips.tar.gz) using curl -o, then extract from that file.
- Added 'set -eux' to the RUN command for better debugging output.
- This addresses the 'gzip: stdin: not in gzip format' error by ensuring a complete download before extraction.
2026-02-22 01:45:16 +01:00
denshooter 48411e432a Fix Docker build: Download libvips tarball directly instead of git clone
- Replaced 'git clone' with 'curl' to download and extract the libvips source tarball. This avoids persistent 'Remote branch not found' errors when cloning specific tags in the Docker environment.
2026-02-22 01:43:51 +01:00
denshooter 6148e5e9ac Fix Docker build: Specify libvips tag explicitly for git clone
- Changed git clone --branch to git clone --branch refs/tags/ to correctly fetch the libvips version by tag, resolving the 'Remote branch not found' error.
2026-02-22 01:27:30 +01:00
denshooter 82c7b5bcc7 Fix Docker build: Add ca-certificates and refine environment variables
- Added ca-certificates to libvips-builder stage to fix SSL verification during git clone
- Refined ENV declarations to avoid build warnings
- Switched back to entrypoint.sh with gosu for better volume permission handling
2026-02-22 01:25:41 +01:00
denshooter 0facc29a97 Fix HEIC/HEIF image loading in Docker and improve Timeline layout
- Rewrote Dockerfile to compile libvips with HEIF support from source
- Set sharp failOn: 'none' to handle minor image metadata warnings
- Improved timeline thumbnails: increased max-h, added object-[center_15%] to prevent cutting off heads, and added subtle hover effects
- Reverted horizontal timeline spacing to restore the original gap
- Polished timeline card padding and font sizes for a more premium look
2026-02-22 01:22:34 +01:00
14 changed files with 211 additions and 85 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.git
*.md
docker-compose.yml
+14 -38
View File
@@ -1,37 +1,17 @@
FROM node:22-alpine AS builder # Stage 1: Build the Next.js app
# sharp 0.34+ bundles prebuilt libvips with HEIF support, no custom build needed
FROM node:22-bullseye-slim AS builder
WORKDIR /app WORKDIR /app
# Install build dependencies for sharp and libvips with HEIF support
# See: https://sharp.pixelplumbing.com/install#alpine
RUN apk add --no-cache \
build-base \
pkgconf \
# libvips runtime dependencies
libjpeg-turbo-dev \
libpng-dev \
libwebp-dev \
tiff-dev \
libexif-dev \
lcms2-dev \
glib-dev \
# libvips itself and its HEIF support
vips-dev \
libheif-dev \
# Codecs for HEIF
libde265-dev \
x265-dev
COPY package*.json ./ COPY package*.json ./
# Tell sharp to use the system-wide libvips we just installed with HEIF support
ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# ---- Runner ---- # Stage 2: Final production image
FROM node:22-alpine AS runner FROM node:22-bullseye-slim AS runner
WORKDIR /app WORKDIR /app
@@ -39,26 +19,22 @@ ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
# Install gosu for entrypoint
RUN apt-get update && apt-get install -y --no-install-recommends \
gosu \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs \ RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && adduser --system --uid 1001 nextjs
# Standalone output # Copy standalone output
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# Sharp native binaries are no longer copied directly, # Entrypoint script to fix volume permissions at startup
# as sharp is built against system libvips and is part of node_modules copied with the standalone app. RUN printf '#!/bin/sh\nmkdir -p /app/data/uploads/photos /app/data/uploads/videos /app/data/uploads/music\nchown -R nextjs:nodejs /app/data 2>/dev/null || true\nexec gosu nextjs node server.js\n' > /app/entrypoint.sh \
# If sharp is needed outside standalone, node_modules would need to be copied.
# COPY --from=builder /app/node_modules/sharp ./node_modules/sharp
# COPY --from=builder /app/node_modules/@img ./node_modules/@img
RUN mkdir -p /app/data/uploads/photos /app/data/uploads/videos /app/data/uploads/music \
&& chown -R nextjs:nodejs /app/data
# Entrypoint fixes data dir permissions at runtime (volume mount overrides)
RUN apk add --no-cache su-exec \
&& printf '#!/bin/sh\nmkdir -p /app/data/uploads/photos /app/data/uploads/videos /app/data/uploads/music\nchown -R nextjs:nodejs /app/data 2>/dev/null || true\nexec su-exec nextjs node server.js\n' > /app/entrypoint.sh \
&& chmod +x /app/entrypoint.sh && chmod +x /app/entrypoint.sh
EXPOSE 3000 EXPOSE 3000
+51
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"framer-motion": "^11.2.0", "framer-motion": "^11.2.0",
"heic-convert": "^2.1.0",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"next": "^16.1.6", "next": "^16.1.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -2334,6 +2335,41 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
"license": "ISC",
"dependencies": {
"heic-decode": "^2.0.0",
"jpeg-js": "^0.4.4",
"pngjs": "^6.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/heic-convert/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2415,12 +2451,27 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/libheif-js": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+1
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"framer-motion": "^11.2.0", "framer-motion": "^11.2.0",
"heic-convert": "^2.1.0",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"next": "^16.1.6", "next": "^16.1.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
+18 -2
View File
@@ -1138,7 +1138,7 @@ export default function AdminPage() {
{entry.media_filenames.split(',').slice(0, 3).map((filename, idx) => ( {entry.media_filenames.split(',').slice(0, 3).map((filename, idx) => (
<img <img
key={idx} key={idx}
src={`/data/uploads/photos/${filename.trim()}`} src={`/api/files/${filename.trim()}`}
alt="" alt=""
className="w-10 h-10 object-cover rounded border border-warm-border" className="w-10 h-10 object-cover rounded border border-warm-border"
/> />
@@ -1608,7 +1608,23 @@ export default function AdminPage() {
</h2> </h2>
{/* Status Filter Tabs */} {/* Status Filter Tabs */}
<div className="flex gap-2 mb-6"> <div className="flex flex-wrap gap-2 mb-6">
{timelineContributions.filter(c => c.status === 'pending').length > 0 && (
<button
onClick={async () => {
if (!confirm(`Alle ${timelineContributions.filter(c => c.status === 'pending').length} ausstehenden Beiträge freigeben?`)) return
await fetch('/api/contributions', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'approve-all-pending' }),
})
loadData()
}}
className="px-4 py-2 rounded-lg text-sm font-lora bg-green-600 hover:bg-green-700 text-white transition-colors"
>
Alle ausstehenden freigeben ({timelineContributions.filter(c => c.status === 'pending').length})
</button>
)}
<button <button
onClick={() => setContributionFilter('review')} onClick={() => setContributionFilter('review')}
className={`px-4 py-2 rounded-lg text-sm font-lora transition-colors ${ className={`px-4 py-2 rounded-lg text-sm font-lora transition-colors ${
+1
View File
@@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export async function GET() { export async function GET() {
const db = getDb() const db = getDb()
const candles = db const candles = db
+46 -20
View File
@@ -1,8 +1,21 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import { cookies } from 'next/headers'
import { createHash } from 'crypto'
export const runtime = 'nodejs' export const runtime = 'nodejs'
async function isAdmin() {
const cookieStore = await cookies()
const token = cookieStore.get('admin_auth')?.value
const expected = createHash('sha256')
.update(process.env.ADMIN_PASSWORD || 'change-me')
.digest('hex')
return token === expected
}
const VALID_CONTRIBUTION_TYPES = ['memory', 'timeline', 'media', 'recipe']
// Simple bad word check // Simple bad word check
function hasBadWords(text: string): { flag: boolean; reason?: string } { function hasBadWords(text: string): { flag: boolean; reason?: string } {
const lower = text.toLowerCase() const lower = text.toLowerCase()
@@ -29,26 +42,10 @@ function hasBadWords(text: string): { flag: boolean; reason?: string } {
// Background AI moderation with Ollama // Background AI moderation with Ollama
async function moderateWithAI(contributionId: number, content: string) { async function moderateWithAI(contributionId: number, content: string) {
console.log(`[AI-Mod] Starting for ${contributionId}`) console.log(`[AI-Mod] Starting for ${contributionId}`)
// Step 1: Instant bad word check // AI check for subtle issues (irrelevant content, hidden insults etc.)
const lowerCheck = content.toLowerCase() // Note: bad words are already checked before this function is called
const badWords = ['hurensohn', 'arschloch', 'wichser', 'fotze'] console.log(`[AI-Mod] Asking AI...`)
const foundBadWord = badWords.find(word => lowerCheck.includes(word))
if (foundBadWord) {
console.log(`[AI-Mod] ⚠️ INSTANT FLAG: "${foundBadWord}" detected!`)
const db = getDb()
db.prepare(`
UPDATE contributions
SET status = 'flagged', moderation_reason = ?
WHERE id = ?
`).run(`Unangemessene Sprache: "${foundBadWord}"`, contributionId)
console.log(`[AI-Mod] ✅ FLAGGED ${contributionId} instantly`)
return
}
// Step 2: AI check for subtle issues (irrelevant content, hidden insults etc.)
console.log(`[AI-Mod] No bad words, asking AI...`)
try { try {
const prompt = `Du prüfst Beiträge für eine Gedenkseite einer verstorbenen Großmutter (Maria Malejka). const prompt = `Du prüfst Beiträge für eine Gedenkseite einer verstorbenen Großmutter (Maria Malejka).
@@ -151,6 +148,13 @@ export async function POST(request: Request) {
) )
} }
if (!VALID_CONTRIBUTION_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid type' },
{ status: 400 }
)
}
// Require content for memory type, title for timeline // Require content for memory type, title for timeline
if (type === 'memory' && !content) { if (type === 'memory' && !content) {
return NextResponse.json( return NextResponse.json(
@@ -216,6 +220,28 @@ export async function POST(request: Request) {
} }
} }
export async function PATCH(request: Request) {
if (!await isAdmin()) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { action } = await request.json()
if (action !== 'approve-all-pending') {
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
}
const db = getDb()
const result = db
.prepare(`UPDATE contributions SET status = 'approved' WHERE status = 'pending'`)
.run()
return NextResponse.json({ updated: result.changes })
} catch (error) {
console.error('[API] Bulk approve error:', error)
return NextResponse.json({ error: 'Failed' }, { status: 500 })
}
}
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
+17 -3
View File
@@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import { convertHeicToJpeg } from '@/lib/heic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const maxDuration = 60 export const maxDuration = 60
@@ -69,12 +70,25 @@ export async function POST(req: NextRequest) {
) )
} }
const filename = `${uploadDir}/${randomUUID()}${ext || '.bin'}` const buffer = Buffer.from(await file.arrayBuffer())
// Convert HEIC/HEIF to JPEG so all browsers can display it
let finalBuffer: Buffer = buffer
let finalExt = ext
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
try {
finalBuffer = await convertHeicToJpeg(buffer)
finalExt = '.jpg'
} catch {
// Conversion failed — keep original
}
}
const filename = `${uploadDir}/${randomUUID()}${finalExt || '.bin'}`
const filePath = path.join(DATA_DIR, 'uploads', filename) const filePath = path.join(DATA_DIR, 'uploads', filename)
await mkdir(path.dirname(filePath), { recursive: true }) await mkdir(path.dirname(filePath), { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer()) await writeFile(filePath, finalBuffer)
await writeFile(filePath, buffer)
// Build caption with uploader info // Build caption with uploader info
let caption = `Von ${(name || 'Anonym').trim()}` let caption = `Von ${(name || 'Anonym').trim()}`
+5 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import sharp from 'sharp' import sharp from 'sharp'
import { convertHeicToJpeg } from '@/lib/heic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -55,12 +56,13 @@ export async function GET(
contentType = 'image/jpeg' contentType = 'image/jpeg'
} else { } else {
try { try {
const converted = await sharp(fs.readFileSync(filePath)).jpeg({ quality: 90 }).toBuffer() const converted = await convertHeicToJpeg(fs.readFileSync(filePath))
fs.writeFileSync(jpegPath, converted) fs.writeFileSync(jpegPath, converted)
fileToSendPath = jpegPath fileToSendPath = jpegPath
ext = '.jpg' ext = '.jpg'
contentType = 'image/jpeg' contentType = 'image/jpeg'
} catch { } catch (e) {
console.error('HEIC conversion failed:', e)
// Conversion failed — keep original path (will likely fail to display in non-Apple browsers) // Conversion failed — keep original path (will likely fail to display in non-Apple browsers)
} }
} }
@@ -83,7 +85,7 @@ export async function GET(
fileToSendPath = resizedPath fileToSendPath = resizedPath
} else { } else {
try { try {
let transformer = sharp(fs.readFileSync(fileToSendPath)) let transformer = sharp(fs.readFileSync(fileToSendPath), { failOn: 'none' })
.resize(targetWidth, null, { withoutEnlargement: true }) .resize(targetWidth, null, { withoutEnlargement: true })
if (ext === '.jpg' || ext === '.jpeg') { if (ext === '.jpg' || ext === '.jpeg') {
+11
View File
@@ -3,9 +3,20 @@ import { getDb } from '@/lib/db'
export const runtime = 'nodejs' export const runtime = 'nodejs'
const VALID_TYPES = ['photo', 'video', 'music']
const VALID_STATUSES = ['approved', 'pending', 'rejected', 'flagged']
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const type = req.nextUrl.searchParams.get('type') const type = req.nextUrl.searchParams.get('type')
const status = req.nextUrl.searchParams.get('status') const status = req.nextUrl.searchParams.get('status')
if (type && !VALID_TYPES.includes(type)) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
}
if (status && !VALID_STATUSES.includes(status)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const db = getDb() const db = getDb()
let query = 'SELECT * FROM media WHERE 1=1' let query = 'SELECT * FROM media WHERE 1=1'
+3 -3
View File
@@ -4,7 +4,7 @@ import path from 'path'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { createHash, randomUUID } from 'crypto' import { createHash, randomUUID } from 'crypto'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import sharp from 'sharp' import { convertHeicToJpeg } from '@/lib/heic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const maxDuration = 60 export const maxDuration = 60
@@ -112,8 +112,8 @@ export async function POST(req: NextRequest) {
// Convert HEIC/HEIF to JPEG so all browsers can display it // Convert HEIC/HEIF to JPEG so all browsers can display it
if (mimeType === 'image/heic' || mimeType === 'image/heif') { if (mimeType === 'image/heic' || mimeType === 'image/heif') {
try { try {
const converted = await sharp(buffer).jpeg({ quality: 90 }).toBuffer() const converted = await convertHeicToJpeg(buffer)
buffer = converted as unknown as Buffer buffer = converted
ext = '.jpg' ext = '.jpg'
mimeType = 'image/jpeg' mimeType = 'image/jpeg'
} catch { } catch {
+15 -14
View File
@@ -75,16 +75,16 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
transition={{ duration: 0.6, delay: 0.1 }} transition={{ duration: 0.6, delay: 0.1 }}
className={`relative flex items-start ${ className={`relative flex items-start ${
isLeft isLeft
? 'pl-10 sm:pl-0 sm:pr-[50%]' ? 'pl-10 sm:pl-0 sm:pr-[52%]'
: 'pl-10 sm:pl-[50%]' : 'pl-10 sm:pl-[52%]'
}`} }`}
> >
{/* Content Card */} {/* Content Card */}
<motion.button <motion.button
onClick={() => setSelectedEntry(entry)} onClick={() => setSelectedEntry(entry)}
whileHover={{ scale: 1.02, y: -2 }} whileHover={{ scale: 1.01, y: -1 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.99 }}
className={`group cursor-pointer text-left w-full bg-white/80 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-4 sm:p-5 hover:shadow-lg transition-all ${ className={`group cursor-pointer text-left w-full bg-white/80 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-3.5 sm:p-4.5 hover:shadow-lg transition-all ${
isLeft ? 'sm:mr-auto' : 'sm:ml-auto' isLeft ? 'sm:mr-auto' : 'sm:ml-auto'
} ${isSpecial ? 'ring-2 ring-warm-gold/30' : ''}`} } ${isSpecial ? 'ring-2 ring-warm-gold/30' : ''}`}
style={{ minWidth: 0 }} style={{ minWidth: 0 }}
@@ -93,24 +93,25 @@ export default function TimelineSection({ entries }: TimelineSectionProps) {
{photos.length > 0 && ( {photos.length > 0 && (
<div className={`grid gap-2 mb-3 ${photos.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}> <div className={`grid gap-2 mb-3 ${photos.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
{photos.slice(0, 2).map((filename, i) => ( {photos.slice(0, 2).map((filename, i) => (
<img <div key={i} className="overflow-hidden rounded-lg">
key={i} <img
src={`/api/files/${filename.trim()}?w=600`} src={`/api/files/${filename.trim()}?w=600`}
alt="" alt=""
className="w-full max-h-60 object-cover rounded-lg" className="w-full max-h-64 object-cover object-[center_15%] group-hover:scale-105 transition-transform duration-700"
loading="lazy" loading="lazy"
/> />
</div>
))} ))}
</div> </div>
)} )}
{/* Date */} {/* Date */}
<div className={`font-cormorant mb-1 ${isSpecial ? 'text-3xl text-warm-gold' : 'text-2xl text-warm-gold'}`}> <div className={`font-cormorant leading-none mb-1.5 ${isSpecial ? 'text-3xl text-warm-gold' : 'text-2xl text-warm-gold'}`}>
{formatDate(entry.year, entry.month, entry.day)} {formatDate(entry.year, entry.month, entry.day)}
</div> </div>
{/* Title */} {/* Title */}
<h3 className={`font-cormorant italic mb-1 group-hover:text-warm-gold transition-colors ${isSpecial ? 'text-xl text-warm-brown font-medium' : 'text-lg text-warm-brown'}`}> <h3 className={`font-cormorant italic mb-1.5 group-hover:text-warm-gold transition-colors ${isSpecial ? 'text-xl text-warm-brown font-medium' : 'text-lg text-warm-brown'}`}>
{entry.title} {entry.title}
</h3> </h3>
+9 -2
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Calendar, MapPin, CheckCircle2, Loader2 } from 'lucide-react' import { Calendar, MapPin, CheckCircle2, Loader2 } from 'lucide-react'
@@ -16,9 +16,16 @@ export default function TimelineUploadSection() {
location: '', location: '',
}) })
const [files, setFiles] = useState<File[]>([]) const [files, setFiles] = useState<File[]>([])
const [objectUrls, setObjectUrls] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [submitSuccess, setSubmitSuccess] = useState(false) const [submitSuccess, setSubmitSuccess] = useState(false)
useEffect(() => {
const urls = files.map(file => URL.createObjectURL(file))
setObjectUrls(urls)
return () => urls.forEach(url => URL.revokeObjectURL(url))
}, [files])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) { if (e.target.files) {
setFiles(Array.from(e.target.files)) setFiles(Array.from(e.target.files))
@@ -219,7 +226,7 @@ export default function TimelineUploadSection() {
{files.map((file, i) => ( {files.map((file, i) => (
<div key={i} className="aspect-square rounded-lg overflow-hidden bg-warm-brown/5"> <div key={i} className="aspect-square rounded-lg overflow-hidden bg-warm-brown/5">
<img <img
src={URL.createObjectURL(file)} src={objectUrls[i] ?? ''}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
+15
View File
@@ -0,0 +1,15 @@
// @ts-expect-error no type declarations available
import heicConvert from 'heic-convert'
/**
* Convert HEIC/HEIF buffer to JPEG.
* Uses heic-convert (pure JS) since sharp's prebuilt libvips lacks HEIF support.
*/
export async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
const result = await heicConvert({
buffer,
format: 'JPEG',
quality: 0.9,
})
return Buffer.from(result)
}