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:
parent
c9efe869ea
commit
c509dc66ed
12 changed files with 458 additions and 137 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ node_modules/
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
progress.md
|
progress.md
|
||||||
|
|
||||||
|
# Uploaded event photos (runtime, persists on the VPS)
|
||||||
|
data/uploads/
|
||||||
|
|
|
||||||
1
SPEC.md
1
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)
|
- Email sending (invites are copied manually; no transactional email provider)
|
||||||
- File uploads from participants (they write text; Fenja attaches documents to meetings via git)
|
- 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
|
- Rich-text editor beyond markdown-lite
|
||||||
- Threaded discussion on contributions (replies from Fenja only, one level deep)
|
- Threaded discussion on contributions (replies from Fenja only, one level deep)
|
||||||
- Dark mode
|
- Dark mode
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import DateField from './fields/DateField.astro';
|
||||||
import DatetimeField from './fields/DatetimeField.astro';
|
import DatetimeField from './fields/DatetimeField.astro';
|
||||||
import NumberField from './fields/NumberField.astro';
|
import NumberField from './fields/NumberField.astro';
|
||||||
import ReadonlyField from './fields/ReadonlyField.astro';
|
import ReadonlyField from './fields/ReadonlyField.astro';
|
||||||
|
import ImageUploadField from './fields/ImageUploadField.astro';
|
||||||
import type { Field } from '../resource-types';
|
import type { Field } from '../resource-types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -47,6 +48,7 @@ const { field, value, error, item } = Astro.props;
|
||||||
{field.kind === 'datetime' && <DatetimeField field={field} value={value} />}
|
{field.kind === 'datetime' && <DatetimeField field={field} value={value} />}
|
||||||
{field.kind === 'number' && <NumberField field={field} value={value} />}
|
{field.kind === 'number' && <NumberField field={field} value={value} />}
|
||||||
{field.kind === 'readonly' && <ReadonlyField field={field} value={value} item={item} />}
|
{field.kind === 'readonly' && <ReadonlyField field={field} value={value} item={item} />}
|
||||||
|
{field.kind === 'image-upload' && <ImageUploadField field={field} value={value} />}
|
||||||
|
|
||||||
{field.helperText && (
|
{field.helperText && (
|
||||||
<p class="bs-helper">{field.helperText}</p>
|
<p class="bs-helper">{field.helperText}</p>
|
||||||
|
|
|
||||||
134
src/admin/components/fields/ImageUploadField.astro
Normal file
134
src/admin/components/fields/ImageUploadField.astro
Normal 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>
|
||||||
|
|
@ -100,6 +100,12 @@ export interface ReadonlyField extends FieldBase {
|
||||||
kind: 'readonly';
|
kind: 'readonly';
|
||||||
render?: (value: unknown, item: Record<string, unknown> | null) => string;
|
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 =
|
export type Field =
|
||||||
| TextField
|
| TextField
|
||||||
|
|
@ -112,7 +118,8 @@ export type Field =
|
||||||
| DateField
|
| DateField
|
||||||
| DatetimeField
|
| DatetimeField
|
||||||
| NumberField
|
| NumberField
|
||||||
| ReadonlyField;
|
| ReadonlyField
|
||||||
|
| ImageUploadField;
|
||||||
|
|
||||||
// ── Columns ─────────────────────────────────────────────────────────────────
|
// ── Columns ─────────────────────────────────────────────────────────────────
|
||||||
interface ColumnBase {
|
interface ColumnBase {
|
||||||
|
|
|
||||||
|
|
@ -204,10 +204,10 @@ export const eventsResource: Resource<Event> = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'photo_url',
|
key: 'photo_url',
|
||||||
label: 'Photo URL',
|
label: 'Event photo',
|
||||||
kind: 'text',
|
kind: 'image-upload',
|
||||||
maxLength: 400,
|
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',
|
key: 'notes_url',
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,14 @@ function validateField(field: Field, value: unknown): string | null {
|
||||||
return 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':
|
case 'readonly':
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
---
|
---
|
||||||
import Avatar from './Avatar.astro';
|
|
||||||
import type { Event, UserPublic } from '../lib/db';
|
import type { Event, UserPublic } from '../lib/db';
|
||||||
import { eventKindLabel, redactName } from '../lib/format';
|
import { eventKindLabel } from '../lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: Event | null;
|
event: Event | null;
|
||||||
attendees: UserPublic[]; // confirmed (status='yes')
|
attendees: UserPublic[]; // confirmed (status='yes') — kept for caller compat, not rendered here
|
||||||
confirmedCount: number;
|
confirmedCount: number;
|
||||||
myRsvp: 'yes' | 'no' | 'interested' | null;
|
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 {
|
function parseUtc(s: string): Date {
|
||||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
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 weekday = event ? fmt({ weekday: 'long' }, event.starts_at).toUpperCase() : '';
|
||||||
const monthShort = event ? fmt({ month: 'short' }, 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 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
|
||||||
<article class="hero" aria-label={`Next gathering: ${event.title}`}>
|
class:list={["hero", { "hero--empty": !event }]}
|
||||||
|
aria-label={event ? `Next gathering: ${event.title}` : 'Next gathering'}
|
||||||
<div class="hero-top">
|
>
|
||||||
<div class="hero-date">
|
{event?.photo_url && (
|
||||||
<span class="hero-weekday">{weekday}</span>
|
<div class="hero-photo" aria-hidden="true">
|
||||||
<span class="hero-day">{dayPadded}</span>
|
<img src={event.photo_url} alt="" loading="lazy" />
|
||||||
<span class="hero-month-time">{monthShort} · {startTime}</span>
|
|
||||||
</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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer class="hero-foot">
|
<!-- Greeting now lives inside the box, top of the card. -->
|
||||||
<p class="hero-status">
|
<div class="hero-greeting">
|
||||||
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
|
<h1 class="hero-greeting-line">{greetingPrefix}<em>{firstName}</em>.</h1>
|
||||||
</p>
|
{memberLabel && (
|
||||||
|
<div class="hero-greeting-meta">
|
||||||
|
<span class="hero-greeting-member">{memberLabel}</span>
|
||||||
|
<span class="hero-greeting-circle">Founding circle</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="POST" class="hero-actions">
|
{event ? (
|
||||||
<input type="hidden" name="action" value="rsvp" />
|
<>
|
||||||
<input type="hidden" name="event_slug" value={event.slug} />
|
<div class="hero-event">
|
||||||
{myRsvp === 'yes' ? (
|
<!-- Label sits above the date + title so it's clear they describe
|
||||||
<>
|
the next event. -->
|
||||||
<span class="hero-confirmed">You're confirmed ✓</span>
|
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
|
||||||
<button type="submit" name="status" value="no" class="hero-change">Change</button>
|
<div class="hero-top">
|
||||||
</>
|
<div class="hero-date">
|
||||||
) : (
|
<span class="hero-weekday">{weekday}</span>
|
||||||
<>
|
<span class="hero-day">{dayPadded}</span>
|
||||||
<button type="submit" name="status" value="no" class="hero-decline">Can't make it</button>
|
<span class="hero-month-time">{monthShort} · {startTime}</span>
|
||||||
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
<div class="hero-mid">
|
||||||
</form>
|
<h2 class="hero-title">{event.title}</h2>
|
||||||
</footer>
|
<p class="hero-desc">{event.description}</p>
|
||||||
</article>
|
<p class="hero-location">{event.location}</p>
|
||||||
) : (
|
</div>
|
||||||
<article class="hero hero--empty">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="hero-foot">
|
||||||
|
<p class="hero-status">
|
||||||
|
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST" class="hero-actions">
|
||||||
|
<input type="hidden" name="action" value="rsvp" />
|
||||||
|
<input type="hidden" name="event_slug" value={event.slug} />
|
||||||
|
{myRsvp === 'yes' ? (
|
||||||
|
<>
|
||||||
|
<span class="hero-confirmed">You're confirmed ✓</span>
|
||||||
|
<button type="submit" name="status" value="no" class="hero-change">Change</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="submit" name="status" value="no" class="hero-decline">Can't make it</button>
|
||||||
|
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
|
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
|
||||||
</article>
|
)}
|
||||||
)}
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hero {
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: var(--on-ink);
|
color: var(--on-ink);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 32px 36px 28px;
|
padding: 32px 36px 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.hero-top {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 140px 1fr auto;
|
grid-template-columns: 140px 1fr;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +172,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
}
|
}
|
||||||
.hero-weekday {
|
.hero-weekday {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--on-ink-muted);
|
color: var(--on-ink-muted);
|
||||||
|
|
@ -136,7 +186,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
}
|
}
|
||||||
.hero-month-time {
|
.hero-month-time {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--on-ink-muted);
|
color: var(--on-ink-muted);
|
||||||
|
|
@ -151,7 +201,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
}
|
}
|
||||||
.hero-eyebrow {
|
.hero-eyebrow {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--on-ink-muted);
|
color: var(--on-ink-muted);
|
||||||
|
|
@ -166,71 +216,56 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--on-ink-body);
|
color: var(--on-ink-body);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 380px;
|
max-width: 380px;
|
||||||
}
|
}
|
||||||
.hero-location {
|
.hero-location {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
color: var(--on-ink-muted);
|
color: var(--on-ink-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Right meta column ───────────────────────────────────────── */
|
/* ── Photo as card background ──────────────────────────────────
|
||||||
.hero-meta {
|
The image fills the whole card behind the content. A multi-stop
|
||||||
display: flex;
|
--ink gradient keeps the left (text) side solid and lets the photo
|
||||||
flex-direction: column;
|
surface on the right, fading back into the box at the bottom so the
|
||||||
align-items: flex-end;
|
footer stays legible. --ink is rgb(44,58,82). */
|
||||||
gap: 10px;
|
.hero-photo {
|
||||||
min-width: 140px;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 70%; /* 30% smaller than the full card */
|
||||||
|
height: 70%;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
.hero-duration {
|
.hero-photo img {
|
||||||
font-family: var(--font-sans);
|
width: 100%;
|
||||||
font-size: 10px;
|
height: 100%;
|
||||||
letter-spacing: var(--tracking-wider);
|
object-fit: cover;
|
||||||
text-transform: uppercase;
|
object-position: center;
|
||||||
color: var(--on-ink-muted);
|
display: block;
|
||||||
text-align: right;
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
.hero-attendees {
|
.hero-photo::after {
|
||||||
list-style: none;
|
content: "";
|
||||||
padding: 0;
|
position: absolute;
|
||||||
margin: 0;
|
inset: 0;
|
||||||
display: flex;
|
pointer-events: none;
|
||||||
flex-direction: column;
|
/* Blend the inner edges (left + bottom) into the box; a light veil at
|
||||||
gap: 6px;
|
the top keeps the greeting meta legible where it overlaps. */
|
||||||
align-items: stretch;
|
background:
|
||||||
}
|
linear-gradient(to left, rgba(44, 58, 82, 0) 38%, var(--ink) 100%),
|
||||||
.hero-attendee {
|
linear-gradient(to bottom, rgba(44, 58, 82, 0) 48%, var(--ink) 100%),
|
||||||
display: flex;
|
linear-gradient(to top, rgba(44, 58, 82, 0) 78%, rgba(44, 58, 82, 0.45) 100%);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bottom strip ────────────────────────────────────────────── */
|
/* ── Bottom strip ────────────────────────────────────────────── */
|
||||||
.hero-foot {
|
.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);
|
border-top: 0.5px solid var(--ink-divider);
|
||||||
padding-top: 22px;
|
padding-top: 22px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -241,7 +276,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
}
|
}
|
||||||
.hero-status {
|
.hero-status {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--on-ink-muted);
|
color: var(--on-ink-muted);
|
||||||
|
|
@ -258,7 +293,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--on-ink-muted);
|
color: var(--on-ink-muted);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -298,7 +333,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--on-ink-muted);
|
color: var(--on-ink-muted);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -308,9 +343,7 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
|
|
||||||
/* ── Empty state ─────────────────────────────────────────────── */
|
/* ── Empty state ─────────────────────────────────────────────── */
|
||||||
.hero--empty {
|
.hero--empty {
|
||||||
align-items: flex-start;
|
min-height: 320px;
|
||||||
min-height: 200px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
.hero-empty {
|
.hero-empty {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
|
|
@ -324,7 +357,14 @@ const overflow = Math.max(0, attendees.length - visibleAttendees.length);
|
||||||
/* ── Responsive ───────────────────────────────────────────────── */
|
/* ── Responsive ───────────────────────────────────────────────── */
|
||||||
@media (max-width: 880px) {
|
@media (max-width: 880px) {
|
||||||
.hero-top { grid-template-columns: 1fr; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
35
src/lib/uploads.ts
Normal file
35
src/lib/uploads.ts
Normal 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',
|
||||||
|
};
|
||||||
55
src/pages/api/admin/upload.ts
Normal file
55
src/pages/api/admin/upload.ts
Normal 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);
|
||||||
|
};
|
||||||
35
src/pages/uploads/[file].ts
Normal file
35
src/pages/uploads/[file].ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -35,6 +35,7 @@ const KNOWN_FIELD_KINDS: ReadonlySet<Field['kind']> = new Set([
|
||||||
'datetime',
|
'datetime',
|
||||||
'number',
|
'number',
|
||||||
'readonly',
|
'readonly',
|
||||||
|
'image-upload',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const KNOWN_COLUMN_KINDS: ReadonlySet<string> = new Set([
|
const KNOWN_COLUMN_KINDS: ReadonlySet<string> = new Set([
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue