feat(events): event photo upload + photo-as-background hero card

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-06-10 17:18:23 +02:00
parent c9efe869ea
commit c509dc66ed
12 changed files with 458 additions and 137 deletions

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ node_modules/
*.db-shm
*.db-wal
progress.md
# Uploaded event photos (runtime, persists on the VPS)
data/uploads/

View file

@ -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

View file

@ -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' && <DatetimeField field={field} value={value} />}
{field.kind === 'number' && <NumberField field={field} value={value} />}
{field.kind === 'readonly' && <ReadonlyField field={field} value={value} item={item} />}
{field.kind === 'image-upload' && <ImageUploadField field={field} value={value} />}
{field.helperText && (
<p class="bs-helper">{field.helperText}</p>

View file

@ -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);
---
<div class="bs-imgup" data-imgup>
<!-- Stored value: the image URL. Set by upload, or pasteable directly. -->
<input
type="text"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
placeholder="Upload below, or paste an image URL"
maxlength={field.maxLength}
data-imgup-value
/>
<div class="bs-imgup-row">
<label class="bs-imgup-pick">
Choose image…
<input type="file" accept="image/png,image/jpeg" data-imgup-file hidden />
</label>
<button type="button" class="bs-imgup-clear" data-imgup-clear hidden>Remove</button>
<span class="bs-imgup-status" data-imgup-status aria-live="polite"></span>
</div>
<div class="bs-imgup-preview" data-imgup-preview hidden>
<img src={v} alt="" data-imgup-img />
</div>
</div>
<style>
.bs-imgup { display: flex; flex-direction: column; gap: var(--space-2); }
.bs-imgup-row { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.bs-imgup-pick {
display: inline-flex;
align-items: center;
font-size: var(--text-label-md);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-container);
color: var(--on-surface);
cursor: pointer;
}
.bs-imgup-pick:hover { background: var(--surface-container-high); }
.bs-imgup-clear {
background: none;
border: none;
color: var(--on-surface-muted);
font-size: var(--text-label-md);
cursor: pointer;
padding: var(--space-2);
}
.bs-imgup-clear:hover { color: var(--color-danger); }
.bs-imgup-status { font-size: var(--text-label-md); color: var(--on-surface-variant); }
.bs-imgup-status.is-error { color: var(--color-danger); }
.bs-imgup-preview {
border-radius: var(--radius-md);
overflow: hidden;
max-width: 280px;
background: var(--surface-container);
}
.bs-imgup-preview img { display: block; width: 100%; height: auto; }
</style>
<script>
document.querySelectorAll<HTMLElement>('[data-imgup]').forEach((root) => {
const valueInput = root.querySelector<HTMLInputElement>('[data-imgup-value]');
const fileInput = root.querySelector<HTMLInputElement>('[data-imgup-file]');
const clearBtn = root.querySelector<HTMLButtonElement>('[data-imgup-clear]');
const status = root.querySelector<HTMLElement>('[data-imgup-status]');
const preview = root.querySelector<HTMLElement>('[data-imgup-preview]');
const img = root.querySelector<HTMLImageElement>('[data-imgup-img]');
if (!valueInput || !fileInput) return;
function setStatus(msg: string, isError = false) {
if (!status) return;
status.textContent = msg;
status.classList.toggle('is-error', isError);
}
function syncPreview() {
const url = valueInput!.value.trim();
const has = url.length > 0;
if (img && has) img.src = url;
preview?.toggleAttribute('hidden', !has);
clearBtn?.toggleAttribute('hidden', !has);
}
syncPreview();
valueInput.addEventListener('input', syncPreview);
clearBtn?.addEventListener('click', () => {
valueInput.value = '';
fileInput.value = '';
setStatus('');
syncPreview();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
if (!['image/png', 'image/jpeg'].includes(file.type)) {
setStatus('Only PNG or JPG images are allowed.', true);
fileInput.value = '';
return;
}
setStatus('Uploading…');
try {
const body = new FormData();
body.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body });
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.url) {
setStatus(data.error ?? 'Upload failed.', true);
return;
}
valueInput.value = data.url;
setStatus('Uploaded ✓');
syncPreview();
} catch {
setStatus('Upload failed. Try again.', true);
}
});
});
</script>

View file

@ -100,6 +100,12 @@ export interface ReadonlyField extends FieldBase {
kind: 'readonly';
render?: (value: unknown, item: Record<string, unknown> | 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 {

View file

@ -204,10 +204,10 @@ export const eventsResource: Resource<Event> = {
},
{
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',

View file

@ -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;
}

View file

@ -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,13 +26,34 @@ 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 ? (
<article class="hero" aria-label={`Next gathering: ${event.title}`}>
<article
class:list={["hero", { "hero--empty": !event }]}
aria-label={event ? `Next gathering: ${event.title}` : 'Next gathering'}
>
{event?.photo_url && (
<div class="hero-photo" aria-hidden="true">
<img src={event.photo_url} alt="" loading="lazy" />
</div>
)}
<!-- Greeting now lives inside the box, top of the card. -->
<div class="hero-greeting">
<h1 class="hero-greeting-line">{greetingPrefix}<em>{firstName}</em>.</h1>
{memberLabel && (
<div class="hero-greeting-meta">
<span class="hero-greeting-member">{memberLabel}</span>
<span class="hero-greeting-circle">Founding circle</span>
</div>
)}
</div>
{event ? (
<>
<div class="hero-event">
<!-- Label sits above the date + title so it's clear they describe
the next event. -->
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<div class="hero-top">
<div class="hero-date">
<span class="hero-weekday">{weekday}</span>
@ -39,32 +62,10 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
</div>
<div class="hero-mid">
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<h2 class="hero-title">{event.title}</h2>
<p class="hero-desc">{event.description}</p>
<p class="hero-location">{event.location}</p>
</div>
<div class="hero-meta">
{event.duration_label && (
<p class="hero-duration">{event.duration_label.toUpperCase()}</p>
)}
{visibleAttendees.length > 0 && (
<ul class="hero-attendees" aria-label="Confirmed attendees">
{visibleAttendees.map(u => (
<li class="hero-attendee">
<span class="hero-attendee-name">{redactName(u.name)}</span>
<Avatar id={u.id} name={u.name} size={18} />
</li>
))}
{overflow > 0 && (
<li class="hero-attendee">
<span class="hero-attendee-name">+{overflow} more</span>
<span class="hero-attendee-overflow" aria-hidden="true">+{overflow}</span>
</li>
)}
</ul>
)}
</div>
</div>
@ -89,27 +90,76 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
)}
</form>
</footer>
</article>
) : (
<article class="hero hero--empty">
</>
) : (
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
</article>
)}
)}
</article>
<style>
.hero {
position: relative;
overflow: hidden;
background: var(--ink);
color: var(--on-ink);
border-radius: 14px;
padding: 32px 36px 28px;
padding: 32px 36px 30px;
display: flex;
flex-direction: column;
gap: 22px;
gap: 44px; /* generous space between greeting and the event */
min-height: 480px; /* much taller — gives the photo room to breathe */
}
/* Event block: label stacked above the date + title grid. */
.hero-event {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Greeting (inside the box, top) ──────────────────────────────── */
.hero-greeting {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.hero-greeting-line {
font-family: var(--font-serif);
font-weight: 400;
font-size: 34px;
line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-ink);
margin: 0;
}
.hero-greeting-line em { font-style: italic; }
.hero-greeting-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
text-align: right;
}
.hero-greeting-member,
.hero-greeting-circle {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
}
.hero-top {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 140px 1fr auto;
grid-template-columns: 140px 1fr;
gap: 32px;
align-items: start;
}
@ -122,7 +172,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
}
.hero-weekday {
font-family: var(--font-sans);
font-size: 10px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
@ -136,7 +186,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
}
.hero-month-time {
font-family: var(--font-sans);
font-size: 11px;
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
@ -151,7 +201,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
}
.hero-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
@ -166,71 +216,56 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
margin: 0;
}
.hero-desc {
font-size: 13px;
font-size: 14px;
line-height: 1.55;
color: var(--on-ink-body);
margin: 0;
max-width: 380px;
}
.hero-location {
font-size: 12px;
font-size: 13px;
color: var(--on-ink-muted);
margin: 0;
}
/* ── Right meta column ───────────────────────────────────────── */
.hero-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
min-width: 140px;
/* ── Photo as card background ──────────────────────────────────
The image fills the whole card behind the content. A multi-stop
--ink gradient keeps the left (text) side solid and lets the photo
surface on the right, fading back into the box at the bottom so the
footer stays legible. --ink is rgb(44,58,82). */
.hero-photo {
position: absolute;
top: 0;
right: 0;
width: 70%; /* 30% smaller than the full card */
height: 70%;
z-index: 0;
}
.hero-duration {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
text-align: right;
margin: 0;
.hero-photo img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.hero-attendees {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
align-items: stretch;
}
.hero-attendee {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.hero-attendee-name {
font-family: var(--font-sans);
font-size: 11px;
color: var(--on-ink-muted);
}
.hero-attendee-overflow {
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 252, 247, 0.15);
color: var(--on-ink);
font-family: var(--font-sans);
font-size: 9px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
.hero-photo::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
/* Blend the inner edges (left + bottom) into the box; a light veil at
the top keeps the greeting meta legible where it overlaps. */
background:
linear-gradient(to left, rgba(44, 58, 82, 0) 38%, var(--ink) 100%),
linear-gradient(to bottom, rgba(44, 58, 82, 0) 48%, var(--ink) 100%),
linear-gradient(to top, rgba(44, 58, 82, 0) 78%, rgba(44, 58, 82, 0.45) 100%);
}
/* ── Bottom strip ────────────────────────────────────────────── */
.hero-foot {
position: relative;
z-index: 1;
margin-top: auto; /* pin to the bottom of the taller card */
border-top: 0.5px solid var(--ink-divider);
padding-top: 22px;
display: flex;
@ -241,7 +276,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
}
.hero-status {
font-family: var(--font-sans);
font-size: 11px;
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
@ -258,7 +293,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 11px;
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
@ -298,7 +333,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 11px;
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
@ -308,9 +343,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
/* ── Empty state ─────────────────────────────────────────────── */
.hero--empty {
align-items: flex-start;
min-height: 200px;
justify-content: center;
min-height: 320px;
}
.hero-empty {
font-family: var(--font-serif);
@ -324,7 +357,14 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) {
.hero-top { grid-template-columns: 1fr; }
.hero-meta { align-items: flex-start; }
.hero-duration, .hero-attendee { justify-content: flex-start; }
}
/* Phone tuning: lighter padding, smaller display date, full-width copy. */
@media (max-width: 480px) {
.hero { padding: 24px 22px 22px; gap: 22px; min-height: 440px; }
.hero-greeting-line { font-size: 27px; }
.hero-day { font-size: 64px; }
.hero-title { font-size: 22px; }
.hero-desc { max-width: none; }
.hero-foot { gap: 12px; }
}
</style>

35
src/lib/uploads.ts Normal file
View file

@ -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
* <cwd>/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<string, string>([
['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<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};

View file

@ -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);
};

View file

@ -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 <img> 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 });
}
};

View file

@ -35,6 +35,7 @@ const KNOWN_FIELD_KINDS: ReadonlySet<Field['kind']> = new Set([
'datetime',
'number',
'readonly',
'image-upload',
]);
const KNOWN_COLUMN_KINDS: ReadonlySet<string> = new Set([