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?.photo_url && (
+
+
+ )}
-
-
-) : (
-
+ {event ? (
+ <>
+
+
+
Next gathering · {eventKindLabel(event.kind).toUpperCase()}
+
+
+ {weekday}
+ {dayPadded}
+ {monthShort} · {startTime}
+
+
+
+
{event.title}
+
{event.description}
+
{event.location}
+
+
+
+
+
+ >
+ ) : (
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([