Compare commits
10 Commits
49d8798e11
...
e3d4f7c96e
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d4f7c96e | |||
| 31dff10636 | |||
| 6f826c66ea | |||
| aa23fb12a5 | |||
| 5061b9c287 | |||
| a6b211a749 | |||
| 48411e432a | |||
| 6148e5e9ac | |||
| 82c7b5bcc7 | |||
| 0facc29a97 |
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
docker-compose.yml
|
||||||
+14
-38
@@ -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
|
||||||
|
|||||||
Generated
+51
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 ${
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()}`
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user