From c509dc66ed355ac2e7ef029958f0d2acb5bcd202 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Wed, 10 Jun 2026 17:18:23 +0200 Subject: [PATCH] feat(events): event photo upload + photo-as-background hero card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin can now upload a png/jpg event photo (SPEC §8 exception added): - new image-upload admin field kind with live preview, uploading via POST /api/admin/upload (fenja-only, type + 5MB validation); - files stored under data/uploads (gitignored, BIFROST_UPLOAD_DIR overridable) and served by GET /uploads/[file] with a traversal guard. Reworks the /pulse event card: the greeting moved inside a taller box, the "next gathering" label sits above the date + title, and the photo renders as a top-right background that blends into the indigo via gradient masks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + SPEC.md | 1 + src/admin/components/FieldRenderer.astro | 2 + .../components/fields/ImageUploadField.astro | 134 ++++++++ src/admin/resource-types.ts | 9 +- src/admin/resources/events.ts | 6 +- src/admin/validate.ts | 8 + src/components/EventHeroCard.astro | 306 ++++++++++-------- src/lib/uploads.ts | 35 ++ src/pages/api/admin/upload.ts | 55 ++++ src/pages/uploads/[file].ts | 35 ++ tests/admin-resources.test.ts | 1 + 12 files changed, 458 insertions(+), 137 deletions(-) create mode 100644 src/admin/components/fields/ImageUploadField.astro create mode 100644 src/lib/uploads.ts create mode 100644 src/pages/api/admin/upload.ts create mode 100644 src/pages/uploads/[file].ts diff --git a/.gitignore b/.gitignore index db44ef0..7d07d48 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ node_modules/ *.db-shm *.db-wal progress.md + +# Uploaded event photos (runtime, persists on the VPS) +data/uploads/ diff --git a/SPEC.md b/SPEC.md index cc13f81..c6f1c0d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -255,6 +255,7 @@ Listed so Claude Code does not wander into them: - Email sending (invites are copied manually; no transactional email provider) - File uploads from participants (they write text; Fenja attaches documents to meetings via git) + - *Exception (added v1):* Fenja admins may upload a single event photo (png/jpg) per event via the admin panel. Stored on the VPS and served by the app. This is the only upload path; participants still cannot upload. - Rich-text editor beyond markdown-lite - Threaded discussion on contributions (replies from Fenja only, one level deep) - Dark mode diff --git a/src/admin/components/FieldRenderer.astro b/src/admin/components/FieldRenderer.astro index 913fe90..609bbc8 100644 --- a/src/admin/components/FieldRenderer.astro +++ b/src/admin/components/FieldRenderer.astro @@ -18,6 +18,7 @@ import DateField from './fields/DateField.astro'; import DatetimeField from './fields/DatetimeField.astro'; import NumberField from './fields/NumberField.astro'; import ReadonlyField from './fields/ReadonlyField.astro'; +import ImageUploadField from './fields/ImageUploadField.astro'; import type { Field } from '../resource-types'; interface Props { @@ -47,6 +48,7 @@ const { field, value, error, item } = Astro.props; {field.kind === 'datetime' && } {field.kind === 'number' && } {field.kind === 'readonly' && } + {field.kind === 'image-upload' && } {field.helperText && (

{field.helperText}

diff --git a/src/admin/components/fields/ImageUploadField.astro b/src/admin/components/fields/ImageUploadField.astro new file mode 100644 index 0000000..3681b59 --- /dev/null +++ b/src/admin/components/fields/ImageUploadField.astro @@ -0,0 +1,134 @@ +--- +import type { ImageUploadField } from '../../resource-types'; + +interface Props { + field: ImageUploadField; + value: unknown; +} + +const { field, value } = Astro.props; +const v = value == null ? '' : String(value); +--- + +
+ + + +
+ + + +
+ + +
+ + + + diff --git a/src/admin/resource-types.ts b/src/admin/resource-types.ts index 6173015..edc93e1 100644 --- a/src/admin/resource-types.ts +++ b/src/admin/resource-types.ts @@ -100,6 +100,12 @@ export interface ReadonlyField extends FieldBase { kind: 'readonly'; render?: (value: unknown, item: Record | null) => string; } +/** Image upload — uploads a png/jpg to /api/admin/upload and stores the + * returned URL as the field value (also pasteable as a plain URL). */ +export interface ImageUploadField extends FieldBase { + kind: 'image-upload'; + maxLength?: number; +} export type Field = | TextField @@ -112,7 +118,8 @@ export type Field = | DateField | DatetimeField | NumberField - | ReadonlyField; + | ReadonlyField + | ImageUploadField; // ── Columns ───────────────────────────────────────────────────────────────── interface ColumnBase { diff --git a/src/admin/resources/events.ts b/src/admin/resources/events.ts index 9fdcc2b..883167d 100644 --- a/src/admin/resources/events.ts +++ b/src/admin/resources/events.ts @@ -204,10 +204,10 @@ export const eventsResource: Resource = { }, { key: 'photo_url', - label: 'Photo URL', - kind: 'text', + label: 'Event photo', + kind: 'image-upload', maxLength: 400, - helperText: 'Optional hero image for the event detail page.', + helperText: 'Optional png/jpg shown as the event photo on the hub. Upload a file or paste an image URL.', }, { key: 'notes_url', diff --git a/src/admin/validate.ts b/src/admin/validate.ts index e1caee4..c84398c 100644 --- a/src/admin/validate.ts +++ b/src/admin/validate.ts @@ -110,6 +110,14 @@ function validateField(field: Field, value: unknown): string | null { return null; } + case 'image-upload': { + if (typeof value !== 'string') return `${field.label} must be a URL`; + if ('maxLength' in field && field.maxLength && value.length > field.maxLength) { + return `${field.label} must be ${field.maxLength} characters or fewer`; + } + return null; + } + case 'readonly': return null; } diff --git a/src/components/EventHeroCard.astro b/src/components/EventHeroCard.astro index 7b98018..3005b10 100644 --- a/src/components/EventHeroCard.astro +++ b/src/components/EventHeroCard.astro @@ -1,16 +1,18 @@ --- -import Avatar from './Avatar.astro'; import type { Event, UserPublic } from '../lib/db'; -import { eventKindLabel, redactName } from '../lib/format'; +import { eventKindLabel } from '../lib/format'; interface Props { event: Event | null; - attendees: UserPublic[]; // confirmed (status='yes') + attendees: UserPublic[]; // confirmed (status='yes') — kept for caller compat, not rendered here confirmedCount: number; myRsvp: 'yes' | 'no' | 'interested' | null; + greetingPrefix: string; // e.g. "Good afternoon, " + firstName: string; + memberLabel?: string | null; // e.g. "MEMBER · 001" } -const { event, attendees, confirmedCount, myRsvp } = Astro.props; +const { event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null } = Astro.props; function parseUtc(s: string): Date { if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); @@ -24,92 +26,140 @@ const dayPadded = event ? String(parseUtc(event.starts_at).getUTCDate()).padSt const weekday = event ? fmt({ weekday: 'long' }, event.starts_at).toUpperCase() : ''; const monthShort = event ? fmt({ month: 'short' }, event.starts_at).toUpperCase() : ''; const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, event.starts_at) : ''; - -const visibleAttendees = attendees.slice(0, 3); -const overflow = Math.max(0, attendees.length - visibleAttendees.length); --- -{event ? ( -
- -
-
- {weekday} - {dayPadded} - {monthShort} · {startTime} -
- -
-

Next gathering · {eventKindLabel(event.kind).toUpperCase()}

-

{event.title}

-

{event.description}

-

{event.location}

-
- -
- {event.duration_label && ( -

{event.duration_label.toUpperCase()}

- )} - {visibleAttendees.length > 0 && ( -
    - {visibleAttendees.map(u => ( -
  • - {redactName(u.name)} - -
  • - ))} - {overflow > 0 && ( -
  • - +{overflow} more - -
  • - )} -
- )} -
+
+ {event?.photo_url && ( + + )} -
-

- {event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED -

+ +
+

{greetingPrefix}{firstName}.

+ {memberLabel && ( +
+ {memberLabel} + Founding circle +
+ )} +
-
- - - {myRsvp === 'yes' ? ( - <> - You're confirmed ✓ - - - ) : ( - <> - - - - )} -
-
-
-) : ( -
+ {event ? ( + <> +
+ +

Next gathering · {eventKindLabel(event.kind).toUpperCase()}

+
+
+ {weekday} + {dayPadded} + {monthShort} · {startTime} +
+ +
+

{event.title}

+

{event.description}

+

{event.location}

+
+
+
+ +
+

+ {event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED +

+ +
+ + + {myRsvp === 'yes' ? ( + <> + You're confirmed ✓ + + + ) : ( + <> + + + + )} +
+
+ + ) : (

Nothing scheduled yet — when we have something, you'll be the first to know.

-
-)} + )} +
diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts new file mode 100644 index 0000000..e1d96e3 --- /dev/null +++ b/src/lib/uploads.ts @@ -0,0 +1,35 @@ +/* --------------------------------------------------------------------------- + * Event photo uploads. + * + * Files are written outside the build output (so they survive redeploys) and + * served back through the /uploads/[file] route. Path defaults to + * /data/uploads; override with BIFROST_UPLOAD_DIR on the VPS. + * + * Scope: admin-only event photos (see SPEC §8 exception). Participants cannot + * upload. + * ------------------------------------------------------------------------- */ + +import { join } from 'node:path'; + +export const UPLOAD_DIR = + process.env.BIFROST_UPLOAD_DIR ?? join(process.cwd(), 'data', 'uploads'); + +/** Accepted MIME types → file extension. */ +export const ALLOWED_IMAGE_TYPES = new Map([ + ['image/png', 'png'], + ['image/jpeg', 'jpg'], +]); + +export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; // 5 MB + +/** Guards the served filename against path traversal. */ +export function isSafeUploadName(name: string): boolean { + return /^[a-z0-9][a-z0-9._-]*$/i.test(name) && !name.includes('..'); +} + +/** Content-type for a stored upload, by extension. */ +export const UPLOAD_CONTENT_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', +}; diff --git a/src/pages/api/admin/upload.ts b/src/pages/api/admin/upload.ts new file mode 100644 index 0000000..4a51061 --- /dev/null +++ b/src/pages/api/admin/upload.ts @@ -0,0 +1,55 @@ +/* --------------------------------------------------------------------------- + * POST /api/admin/upload — fenja-only event photo upload. + * + * Accepts multipart/form-data with a single `file` (png/jpg, ≤5 MB), writes it + * to the upload dir under a random name, and returns { url } pointing at the + * /uploads/[file] serve route. The caller (admin image-upload field) stores + * that url in the event's photo_url. + * ------------------------------------------------------------------------- */ + +import type { APIRoute } from 'astro'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { UPLOAD_DIR, ALLOWED_IMAGE_TYPES, MAX_UPLOAD_BYTES } from '../../../lib/uploads'; + +export const prerender = false; + +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export const POST: APIRoute = async ({ locals, request }) => { + if (locals.user?.role !== 'fenja') return json({ error: 'Forbidden' }, 403); + + let form: FormData; + try { + form = await request.formData(); + } catch { + return json({ error: 'Expected multipart form data.' }, 400); + } + + const file = form.get('file'); + if (!(file instanceof File) || file.size === 0) { + return json({ error: 'No file provided.' }, 400); + } + + const ext = ALLOWED_IMAGE_TYPES.get(file.type); + if (!ext) return json({ error: 'Only PNG or JPG images are allowed.' }, 415); + if (file.size > MAX_UPLOAD_BYTES) { + return json({ error: 'Image must be 5 MB or smaller.' }, 413); + } + + const name = `${randomUUID()}.${ext}`; + try { + await mkdir(UPLOAD_DIR, { recursive: true }); + await writeFile(join(UPLOAD_DIR, name), Buffer.from(await file.arrayBuffer())); + } catch { + return json({ error: 'Could not save the image. Try again.' }, 500); + } + + return json({ url: `/uploads/${name}` }, 200); +}; diff --git a/src/pages/uploads/[file].ts b/src/pages/uploads/[file].ts new file mode 100644 index 0000000..c733d6a --- /dev/null +++ b/src/pages/uploads/[file].ts @@ -0,0 +1,35 @@ +/* --------------------------------------------------------------------------- + * GET /uploads/[file] — serve an uploaded event photo from the upload dir. + * + * Runtime-written files live outside the build output, so they're streamed + * here rather than served as static assets. Gated by the global auth + * middleware (viewers are logged in; the request carries the session + * cookie). Filename is validated to prevent path traversal. + * ------------------------------------------------------------------------- */ + +import type { APIRoute } from 'astro'; +import { readFile } from 'node:fs/promises'; +import { join, extname } from 'node:path'; +import { UPLOAD_DIR, isSafeUploadName, UPLOAD_CONTENT_TYPES } from '../../lib/uploads'; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const file = params.file ?? ''; + if (!isSafeUploadName(file)) return new Response('Not found', { status: 404 }); + + const type = UPLOAD_CONTENT_TYPES[extname(file).toLowerCase()]; + if (!type) return new Response('Not found', { status: 404 }); + + try { + const buf = await readFile(join(UPLOAD_DIR, file)); + return new Response(new Uint8Array(buf), { + headers: { + 'Content-Type': type, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } catch { + return new Response('Not found', { status: 404 }); + } +}; diff --git a/tests/admin-resources.test.ts b/tests/admin-resources.test.ts index bce6ab1..02a9c20 100644 --- a/tests/admin-resources.test.ts +++ b/tests/admin-resources.test.ts @@ -35,6 +35,7 @@ const KNOWN_FIELD_KINDS: ReadonlySet = new Set([ 'datetime', 'number', 'readonly', + 'image-upload', ]); const KNOWN_COLUMN_KINDS: ReadonlySet = new Set([