Compare commits
8 commits
a520e8534e
...
5702859e37
| Author | SHA1 | Date | |
|---|---|---|---|
| 5702859e37 | |||
| 50d5922dcd | |||
| c509dc66ed | |||
| c9efe869ea | |||
| 3cf7171eb2 | |||
| b156f5b02b | |||
| 4c4df45f0c | |||
| 59842432bd |
41 changed files with 935 additions and 244 deletions
|
|
@ -47,7 +47,21 @@
|
|||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)",
|
||||
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/roadmap)",
|
||||
"Bash(grep -nE \"\\\\.rr-fade-left, \\\\.rr-fade-right|rr-fade-left \\\\{|rr-fade-right \\\\{\" src/components/RoadmapRoute.astro)",
|
||||
"Bash(awk -F: '{print $1}')"
|
||||
"Bash(awk -F: '{print $1}')",
|
||||
"Bash(chmod +x /home/jonathan/.claude/statusline-command.sh)",
|
||||
"Bash(bash -n /home/jonathan/.claude/statusline-command.sh)",
|
||||
"Bash(command -v python3)",
|
||||
"Bash(command -v python)",
|
||||
"Bash(cd *)",
|
||||
"Bash(break)",
|
||||
"Bash(sed -n \"/export function createDispatch/,/^}/p\" src/lib/db.ts)",
|
||||
"Bash(sed -n \"/export function dispatchSlug/,/^}/p\" src/lib/format.ts)",
|
||||
"Bash(git check-ignore *)",
|
||||
"Bash(sed -i -E 's/font-size: 15px/font-size: 16px/g; s/font-size: 14px/font-size: 15px/g; s/font-size: 13px/font-size: 14px/g; s/font-size: 12px/font-size: 13px/g; s/font-size: 11px/font-size: 12px/g; s/font-size: 10px/font-size: 11px/g; s/font-size: 9px/font-size: 10px/g' src/components/EventHeroCard.astro)",
|
||||
"Bash(sed -n '/@media \\(max-width: 880px\\)/,/^ }/p' src/components/EventHeroCard.astro)",
|
||||
"Bash(sed -n '1,80p' src/middleware.ts)",
|
||||
"Bash(sed -n '1,80p' src/middleware/index.ts)",
|
||||
"Bash(sed -n '1,40p' src/pages/api/join.ts)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ node_modules/
|
|||
*.db-shm
|
||||
*.db-wal
|
||||
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)
|
||||
- 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
|
||||
|
|
|
|||
39
migrations/0008_roadmap_planned.sql
Normal file
39
migrations/0008_roadmap_planned.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
-- Roadmap status enum gains a fifth value `planned` for items that are
|
||||
-- committed and scheduled but not yet started — sitting between `in_beta`
|
||||
-- and `exploring` in the progression.
|
||||
--
|
||||
-- SQLite can't widen a CHECK constraint in place, so this is a full table
|
||||
-- rebuild (same approach as 0006). roadmap_attributions has an ON DELETE
|
||||
-- CASCADE FK to roadmap_items(id), so foreign keys are toggled off around
|
||||
-- the rebuild to preserve attribution rows across the DROP/RENAME. The
|
||||
-- metadata_text column added in 0007 is carried through.
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE roadmap_items_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'exploring'
|
||||
CHECK(status IN ('shipping','in_beta','planned','exploring','considering')),
|
||||
target TEXT,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
shipped_at TEXT,
|
||||
metadata_text TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT INTO roadmap_items_new
|
||||
(id, title, description, status, target, display_order, shipped_at, metadata_text, created_at, updated_at)
|
||||
SELECT
|
||||
id, title, description, status, target, display_order, shipped_at, metadata_text, created_at, updated_at
|
||||
FROM roadmap_items;
|
||||
|
||||
DROP TABLE roadmap_items;
|
||||
ALTER TABLE roadmap_items_new RENAME TO roadmap_items;
|
||||
|
||||
CREATE INDEX idx_roadmap_status ON roadmap_items(status, display_order);
|
||||
CREATE INDEX idx_roadmap_shipped ON roadmap_items(shipped_at);
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
|
@ -802,6 +802,7 @@
|
|||
.pill-declined { background: rgba(0, 0, 0, 0.04); color: var(--on-surface-muted); }
|
||||
.pill-shipping { background: rgba(109, 140, 124, 0.18); color: #5a7268; }
|
||||
.pill-in-beta { background: rgba(185, 107, 88, 0.10); color: #b96b58; }
|
||||
.pill-planned { background: rgba(90, 109, 131, 0.12); color: #5a6d83; }
|
||||
.pill-exploring { background: rgba(186, 186, 176, 0.20); color: var(--on-surface-variant); }
|
||||
.pill-considering{ background: rgba(186, 186, 176, 0.10); color: var(--on-surface-muted); }
|
||||
.pill-active { background: rgba(109, 140, 124, 0.15); color: #6d8c7c; }
|
||||
|
|
@ -1415,3 +1416,17 @@
|
|||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.bs-copy-btn:hover { opacity: 0.88; }
|
||||
|
||||
/* ── Phone (≤600px) ─────────────────────────────────────────────────
|
||||
The 767px breakpoint already stacks the sidebar + two-pane and the list
|
||||
grid. This pass keeps the chrome from overflowing on a real phone:
|
||||
lighter padding, a search box that can shrink, stacked summary rows and
|
||||
panel footer buttons. Admin is fenja-only, so this is a "fit, don't
|
||||
overflow" pass rather than a native mobile redesign. */
|
||||
@media (max-width: 600px) {
|
||||
.bs-topbar { padding: 0 var(--space-4); }
|
||||
.bs-main { padding: var(--space-4) var(--space-4); }
|
||||
.bs-search-form { min-width: 0; }
|
||||
.bs-summary-row { grid-template-columns: 1fr; gap: var(--space-1); }
|
||||
.bs-panel-foot { flex-direction: column; align-items: stretch; gap: var(--space-2); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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';
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
|||
pillVariants: {
|
||||
shipping: { label: 'Shipping', class: 'pill-shipping' },
|
||||
in_beta: { label: 'In beta', class: 'pill-in-beta' },
|
||||
planned: { label: 'Planned', class: 'pill-planned' },
|
||||
exploring: { label: 'Exploring', class: 'pill-exploring' },
|
||||
considering: { label: 'Considering', class: 'pill-considering' },
|
||||
},
|
||||
|
|
@ -69,6 +70,7 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
|||
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
|
||||
{ key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
|
||||
{ key: 'planned', label: 'Planned', predicate: (i) => i.status === 'planned' },
|
||||
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
|
||||
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
|
||||
],
|
||||
|
|
@ -99,6 +101,7 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
|||
options: [
|
||||
{ value: 'shipping', label: 'Shipping' },
|
||||
{ value: 'in_beta', label: 'In beta' },
|
||||
{ value: 'planned', label: 'Planned' },
|
||||
{ value: 'exploring', label: 'Exploring' },
|
||||
{ value: 'considering', label: 'Considering' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
getAllUsersPublic,
|
||||
getUserPublicById,
|
||||
updateUserAdminFields,
|
||||
updateUserEmail,
|
||||
updateUserProfile,
|
||||
updateUserRole,
|
||||
deactivateUser,
|
||||
|
|
@ -217,7 +218,15 @@ export const usersResource: Resource<UserPublic> = {
|
|||
'Changing the role has real access consequences. Setting to Council also allocates a member number.',
|
||||
},
|
||||
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
|
||||
{ key: 'email', label: 'Email', kind: 'text', readOnly: true },
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
kind: 'text',
|
||||
required: true,
|
||||
maxLength: 200,
|
||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
helperText: 'The member’s login identity. Normalised to lowercase on save; must be unique.',
|
||||
},
|
||||
{ key: 'organisation', label: 'Organisation', kind: 'text', readOnly: true,
|
||||
helperText: 'Set at sign-up; editing is not yet supported.' },
|
||||
{ key: 'bio', label: 'Bio', kind: 'textarea', rows: 4 },
|
||||
|
|
@ -282,6 +291,13 @@ export const usersResource: Resource<UserPublic> = {
|
|||
const current = getUserPublicById(id);
|
||||
if (!current) throw new Error(`User ${id} not found`);
|
||||
|
||||
// Email (login identity) — only written when changed. Throws on a
|
||||
// collision, which the save handler surfaces as a form error.
|
||||
const newEmail = String(data.email ?? '').trim().toLowerCase();
|
||||
if (newEmail && newEmail !== current.email) {
|
||||
updateUserEmail(id, newEmail);
|
||||
}
|
||||
|
||||
// Profile fields (name + bio).
|
||||
const newName = String(data.name ?? current.name);
|
||||
const newBio = String(data.bio ?? current.bio ?? '');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -85,14 +85,14 @@ const authorRole = latest?.author_title ?? 'team';
|
|||
}
|
||||
.rr-dispatch-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.rr-dispatch-kind {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
|
|
@ -106,7 +106,7 @@ const authorRole = latest?.author_title ?? 'team';
|
|||
|
||||
.rr-dispatch-all {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--on-surface-variant);
|
||||
text-transform: uppercase;
|
||||
|
|
@ -132,13 +132,13 @@ const authorRole = latest?.author_title ?? 'team';
|
|||
}
|
||||
.rr-dispatch-text { max-width: 720px; }
|
||||
.rr-dispatch-p1 {
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--on-surface);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.rr-dispatch-p2 {
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
|
|
@ -158,12 +158,12 @@ const authorRole = latest?.author_title ?? 'team';
|
|||
}
|
||||
.rr-dispatch-author-text { text-align: right; }
|
||||
.rr-dispatch-author-name {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.rr-dispatch-author-role {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
margin: 1px 0 0;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
|
@ -178,12 +178,12 @@ const authorRole = latest?.author_title ?? 'team';
|
|||
justify-content: center;
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-dispatch-cta {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--pigment-terracotta);
|
||||
text-transform: uppercase;
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const tags = readFocusTags(member.focus_tags);
|
|||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const { items } = Astro.props;
|
|||
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
||||
shipping: 'SHIPPING',
|
||||
in_beta: 'IN BETA',
|
||||
planned: 'PLANNED',
|
||||
exploring: 'EXPLORING',
|
||||
considering: 'CONSIDERING',
|
||||
};
|
||||
|
|
@ -17,6 +18,7 @@ const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
|||
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
|
||||
shipping: 'var(--pigment-copper)',
|
||||
in_beta: 'var(--pigment-terracotta)',
|
||||
planned: 'var(--pigment-indigo)',
|
||||
exploring: '#b4b2a9',
|
||||
considering: '#b4b2a9',
|
||||
};
|
||||
|
|
@ -24,6 +26,7 @@ const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
|
|||
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
|
||||
shipping: 'var(--pigment-copper)',
|
||||
in_beta: 'var(--pigment-terracotta)',
|
||||
planned: 'var(--pigment-indigo)',
|
||||
exploring: '#b4b2a9',
|
||||
considering: '#d4d2c8',
|
||||
};
|
||||
|
|
@ -146,7 +149,7 @@ const hasArrows = items.length > 3;
|
|||
}
|
||||
.roadmap-all {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
|
|
@ -230,7 +233,7 @@ const hasArrows = items.length > 3;
|
|||
}
|
||||
.card-status-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -243,7 +246,7 @@ const hasArrows = items.length > 3;
|
|||
margin: 0;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -15,28 +15,39 @@ const CONTENT_MAX = 1152;
|
|||
const DEFAULT_PADDING = 60;
|
||||
const paddingLeft = Math.max(DEFAULT_PADDING, (viewportWidth - CONTENT_MAX) / 2);
|
||||
|
||||
// Trailing room past the last milestone = a quarter of the viewport, so the
|
||||
// final item can scroll until it sits halfway between the right edge and the
|
||||
// screen centre (~0.75 of the viewport). The drawn line extends the same
|
||||
// distance so it keeps going as that item arrives. (Client recompute redoes
|
||||
// this with the real viewport; this is the SSR fallback.)
|
||||
const trailing = Math.round(viewportWidth * 0.25);
|
||||
const layout = computeRouteLayout({
|
||||
itemCount: items.length,
|
||||
viewportWidth,
|
||||
paddingLeft,
|
||||
paddingRight: trailing,
|
||||
tailLength: trailing,
|
||||
});
|
||||
const travelledStop = travelledStopFor(items.map(i => i.status));
|
||||
|
||||
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
||||
shipping: 'SHIPPING',
|
||||
in_beta: 'IN BETA',
|
||||
planned: 'PLANNED',
|
||||
exploring: 'EXPLORING',
|
||||
considering: 'CONSIDERING',
|
||||
};
|
||||
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
|
||||
shipping: '#6d8c7c',
|
||||
in_beta: '#b96b58',
|
||||
planned: '#5a6d83',
|
||||
exploring: '#b4b2a9',
|
||||
considering: '#b4b2a9',
|
||||
};
|
||||
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
|
||||
shipping: '#6d8c7c',
|
||||
in_beta: '#b96b58',
|
||||
planned: '#5a6d83',
|
||||
exploring: '#b4b2a9',
|
||||
considering: '#d4d2c8',
|
||||
};
|
||||
|
|
@ -169,6 +180,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
const MIN_SPACING = 320;
|
||||
const PADDING_X = 60;
|
||||
const CONTENT_MAX = 1152; // matches --content-max (72rem)
|
||||
const MID_Y = 210; // vertical centreline = track height (420) / 2
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
||||
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
||||
|
|
@ -184,6 +196,9 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
|
||||
const itemCount = milestones.length;
|
||||
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
|
||||
// Current horizontal positions, kept in sync by recompute() — used by
|
||||
// the scroll-proximity focus effect.
|
||||
let itemXs: number[] = [];
|
||||
|
||||
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
|
||||
function recompute() {
|
||||
|
|
@ -194,7 +209,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
// Match the SSR offset — first item aligns with the content-column
|
||||
// left edge so the route lines up with the dispatch banner below.
|
||||
const paddingLeft = Math.max(PADDING_X, (vw - CONTENT_MAX) / 2);
|
||||
const trackWidth = paddingLeft + usableWidth + PADDING_X;
|
||||
// Trailing room = a quarter of the viewport so the final milestone can
|
||||
// scroll until it sits halfway to the screen centre (~0.75 of vw).
|
||||
const trailing = Math.round(vw * 0.25);
|
||||
const trackWidth = paddingLeft + usableWidth + trailing;
|
||||
|
||||
const itemX: number[] = [];
|
||||
for (let i = 0; i < itemCount; i += 1) {
|
||||
|
|
@ -215,6 +233,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
const cx = (itemX[i - 1] + itemX[i]) / 2;
|
||||
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
|
||||
}
|
||||
// Trailing tail: continue the line past the last milestone, easing
|
||||
// back to the centreline so it keeps going as that item scrolls in.
|
||||
const lastX = itemX[itemCount - 1];
|
||||
const lastY = itemY[itemCount - 1];
|
||||
const tailEndX = lastX + trailing;
|
||||
const tcx = (lastX + tailEndX) / 2;
|
||||
d += ` C ${tcx} ${lastY}, ${tcx} ${MID_Y}, ${tailEndX} ${MID_Y}`;
|
||||
}
|
||||
|
||||
// Apply.
|
||||
|
|
@ -222,6 +247,25 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
svg!.setAttribute('width', String(trackWidth));
|
||||
if (pathD && d) pathD.setAttribute('d', d);
|
||||
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
|
||||
itemXs = itemX;
|
||||
}
|
||||
|
||||
/* Scroll-proximity focus: emphasise the milestone nearest the centre
|
||||
of the viewport and let those toward the edges recede + dim. Driven
|
||||
every frame that the track moves (via updateNav), so movement feels
|
||||
alive rather than a flat pan. Not parallax — every milestone still
|
||||
tracks the scroll 1:1; only scale + opacity shift with position. */
|
||||
function updateFocus() {
|
||||
if (!scroll || itemXs.length === 0) return;
|
||||
const center = scroll.scrollLeft + scroll.clientWidth / 2;
|
||||
const half = Math.max(1, scroll.clientWidth / 2);
|
||||
milestones.forEach((m, i) => {
|
||||
const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half);
|
||||
const scale = (1 - 0.10 * t).toFixed(3);
|
||||
const op = (1 - 0.42 * t).toFixed(3);
|
||||
m.style.transform = `translate(-50%, -50%) scale(${scale})`;
|
||||
m.style.opacity = op;
|
||||
});
|
||||
}
|
||||
|
||||
/* Edge state — fades + advance disable. */
|
||||
|
|
@ -232,6 +276,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
|
||||
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
|
||||
if (advance) advance.classList.toggle('rr-at-end', atEnd);
|
||||
updateFocus();
|
||||
}
|
||||
|
||||
/* ── Unified scroll handling: wheel, drag, animated glide. ──
|
||||
|
|
@ -248,10 +293,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
let velocity = 0; // px/ms, signed (positive = pointer moving right)
|
||||
let momentumRAF: number | null = null;
|
||||
let animateRAF: number | null = null;
|
||||
let wheelRAF: number | null = null;
|
||||
let wheelTarget = 0; // eased target scrollLeft for wheel/trackpad input
|
||||
|
||||
function cancelAnims() {
|
||||
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
|
||||
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
|
||||
if (wheelRAF !== null) { cancelAnimationFrame(wheelRAF); wheelRAF = null; }
|
||||
}
|
||||
|
||||
function animateScrollTo(target: number, durationMs: number) {
|
||||
|
|
@ -276,9 +324,34 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||||
if (dx === 0) return;
|
||||
e.preventDefault();
|
||||
cancelAnims();
|
||||
scroll!.scrollLeft += dx;
|
||||
|
||||
// Drop any drag-momentum or arrow glide that's mid-flight, but keep
|
||||
// building onto the wheel target so quick successive ticks accumulate
|
||||
// distance and the glide stays continuous.
|
||||
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
|
||||
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
|
||||
|
||||
const max = scroll!.scrollWidth - scroll!.clientWidth;
|
||||
const base = wheelRAF !== null ? wheelTarget : scroll!.scrollLeft;
|
||||
wheelTarget = Math.max(0, Math.min(max, base + dx));
|
||||
|
||||
// Ease scrollLeft toward the target each frame (~0.2 of the remaining
|
||||
// distance), so the wheel feels like a smooth glide rather than a jump.
|
||||
if (wheelRAF === null) {
|
||||
const step = () => {
|
||||
const diff = wheelTarget - scroll!.scrollLeft;
|
||||
if (Math.abs(diff) < 0.5) {
|
||||
scroll!.scrollLeft = wheelTarget;
|
||||
wheelRAF = null;
|
||||
updateNav();
|
||||
return;
|
||||
}
|
||||
scroll!.scrollLeft += diff * 0.2;
|
||||
updateNav();
|
||||
wheelRAF = requestAnimationFrame(step);
|
||||
};
|
||||
wheelRAF = requestAnimationFrame(step);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Drag — pointer events; momentum on release.
|
||||
|
|
@ -427,7 +500,22 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
|
||||
.rr-milestone {
|
||||
position: absolute;
|
||||
/* Inline transform/opacity are driven per-frame from JS based on each
|
||||
milestone's distance from the viewport centre, so the track comes
|
||||
alive as you move it (centre milestone emphasised, edges recede).
|
||||
The short ease softens the per-frame updates into a glide. */
|
||||
transform: translate(-50%, -50%);
|
||||
transition: transform .2s ease-out, opacity .2s ease-out;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
/* A hovered/focused card always reads at full size and brightness,
|
||||
regardless of where it sits along the route — overrides the inline
|
||||
focus styles JS sets. */
|
||||
.rr-milestone:has(.rr-card:hover),
|
||||
.rr-milestone:has(.rr-card:focus-visible) {
|
||||
transform: translate(-50%, -50%) scale(1) !important;
|
||||
opacity: 1 !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.rr-dot {
|
||||
|
|
@ -474,8 +562,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
|
||||
.rr-card {
|
||||
display: block;
|
||||
width: 220px;
|
||||
padding: 12px 14px;
|
||||
width: 240px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
|
|
@ -501,16 +589,16 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
|
||||
.rr-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 6px;
|
||||
margin: 0 0 7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-card-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -526,20 +614,20 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
}
|
||||
.rr-card:hover .rr-more,
|
||||
.rr-card:focus-visible .rr-more {
|
||||
max-height: 280px;
|
||||
max-height: 340px;
|
||||
opacity: 1;
|
||||
margin-top: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.rr-desc {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.rr-trail {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
|
|
@ -658,7 +746,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
}
|
||||
.rrm-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
|
|
@ -666,21 +754,21 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
}
|
||||
.rrm-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
font-size: 21px;
|
||||
line-height: 1.25;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.rrm-desc {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
.rrm-trail {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,21 @@ const year = new Date().getFullYear();
|
|||
<span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span>
|
||||
</a>
|
||||
|
||||
<nav class="nav-right" aria-label="Main navigation">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
id="nav-toggle"
|
||||
aria-label="Menu"
|
||||
aria-controls="nav-menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<path class="nav-toggle-bars" d="M3 6h18M3 12h18M3 18h18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path class="nav-toggle-x" d="M5 5l14 14M19 5L5 19" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<nav class="nav-right" id="nav-menu" aria-label="Main navigation">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<a
|
||||
href={href}
|
||||
|
|
@ -121,40 +135,41 @@ const year = new Date().getFullYear();
|
|||
color: var(--on-surface);
|
||||
}
|
||||
.wordmark {
|
||||
height: 20px;
|
||||
height: 30px; /* 50% larger than the prior 20px lockup */
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
.wordmark-sep {
|
||||
/* Flex-centred against the logo height so the dot sits on the vertical
|
||||
middle of the "Fenja AI" logo. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
color: var(--on-surface-muted);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
/* Optical kern — the bullet's typographic centre sits slightly above
|
||||
its baseline in Newsreader; this nudges it onto the visual midline. */
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
/* Italic Newsreader renders ~10% visually taller than regular at the
|
||||
same font-size — the cursive B has a flourish extending past the
|
||||
cap line. Drop Bifrost to 16px so its cap+flourish optical height
|
||||
matches Project's 18px cap, and use inline-block + tiny vertical
|
||||
padding so the gradient-clip bbox doesn't chop the flourish off. */
|
||||
/* Project (regular) + Bifrost (italic) share a baseline. Italic Newsreader
|
||||
renders a touch taller at the same size, so Bifrost is set 1px smaller so
|
||||
the two words read at the same cap height. inline-block + small symmetric
|
||||
padding keeps the gradient-clip bbox from chopping the italic flourish. */
|
||||
.wordmark-project,
|
||||
.wordmark-bifrost {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
line-height: 1.4;
|
||||
line-height: 1;
|
||||
}
|
||||
.wordmark-project {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.wordmark-bifrost {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
font-size: 19px;
|
||||
font-style: italic;
|
||||
padding: 3px 0 1px;
|
||||
padding: 2px 0;
|
||||
vertical-align: baseline;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
|
|
@ -169,6 +184,23 @@ const year = new Date().getFullYear();
|
|||
color: transparent;
|
||||
}
|
||||
|
||||
/* ── Mobile menu toggle (hidden on desktop) ─────────────────────── */
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--on-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.nav-toggle .nav-toggle-x { display: none; }
|
||||
|
||||
/* ── Nav links ──────────────────────────────────────────────────── */
|
||||
.nav-right {
|
||||
display: flex;
|
||||
|
|
@ -302,4 +334,80 @@ const year = new Date().getFullYear();
|
|||
color: var(--on-surface-variant);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
|
||||
@media (max-width: 767px) {
|
||||
.nav-inner { padding: 0 var(--space-5); gap: var(--space-3); }
|
||||
|
||||
.nav-toggle { display: inline-flex; }
|
||||
.nav.open .nav-toggle-bars { display: none; }
|
||||
.nav.open .nav-toggle-x { display: inline; }
|
||||
|
||||
/* Right-hand nav becomes a full-width dropdown under the bar. */
|
||||
.nav-right {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: 0;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: var(--space-2) var(--space-5) var(--space-4);
|
||||
background: var(--glass-surface);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border-bottom: var(--ghost-border);
|
||||
box-shadow: var(--shadow-float);
|
||||
display: none;
|
||||
}
|
||||
.nav.open .nav-right { display: flex; }
|
||||
|
||||
/* Comfortable tap targets in the dropdown. */
|
||||
.nav-right .nav-link,
|
||||
.nav-right .nav-user-name,
|
||||
.nav-right .logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
font-size: var(--text-body-md);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.nav-divider {
|
||||
width: auto;
|
||||
height: 1px;
|
||||
margin: var(--space-2) 0;
|
||||
transform: scaleY(0.5);
|
||||
}
|
||||
.nav-logout-form { width: 100%; }
|
||||
.logout-btn { justify-content: flex-start; }
|
||||
|
||||
/* Footer stacks. */
|
||||
.footer-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Mobile nav: toggle the dropdown, keep aria in sync, close on link tap or
|
||||
// when the viewport grows back to desktop.
|
||||
const nav = document.querySelector<HTMLElement>('.nav');
|
||||
const toggle = nav?.querySelector<HTMLButtonElement>('#nav-toggle');
|
||||
const menu = nav?.querySelector<HTMLElement>('#nav-menu');
|
||||
|
||||
function setOpen(open: boolean) {
|
||||
if (!nav || !toggle) return;
|
||||
nav.classList.toggle('open', open);
|
||||
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
toggle?.addEventListener('click', () => setOpen(!nav!.classList.contains('open')));
|
||||
menu?.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => setOpen(false)));
|
||||
window.addEventListener('resize', () => { if (window.innerWidth > 767) setOpen(false); });
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -152,6 +152,15 @@ export function updateUserProfile(id: number, name: string, bio: string): void {
|
|||
db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id);
|
||||
}
|
||||
|
||||
/** Update a user's email — their login identity. Throws if the address is
|
||||
* already used by another account (the column is UNIQUE). */
|
||||
export function updateUserEmail(id: number, email: string): void {
|
||||
const clash = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?')
|
||||
.get(email, id) as { id: number } | undefined;
|
||||
if (clash) throw new Error('That email is already in use by another account.');
|
||||
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
|
||||
}
|
||||
|
||||
/** Returns the newly-allocated member_number when the transition lands on
|
||||
* cab and the user had none; null otherwise. Callers may ignore. */
|
||||
export function updateUserRole(id: number, role: Role): { allocated: number | null } {
|
||||
|
|
@ -213,7 +222,8 @@ export function updateUserAdminFields(id: number, data: {
|
|||
export function slugifyName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics
|
||||
.replace(/ø/g, 'o').replace(/æ/g, 'ae').replace(/å/g, 'a') // Danish letters NFKD leaves intact
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip remaining diacritics
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
|
@ -688,7 +698,7 @@ export function countPulseParticipants(pulseId: number): number {
|
|||
|
||||
// ── Roadmap items ────────────────────────────────────────────────
|
||||
|
||||
export type RoadmapStatus = 'shipping' | 'in_beta' | 'exploring' | 'considering';
|
||||
export type RoadmapStatus = 'shipping' | 'in_beta' | 'planned' | 'exploring' | 'considering';
|
||||
|
||||
export interface RoadmapItem {
|
||||
id: number;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ export interface LayoutOpts {
|
|||
paddingX?: number; // default 60 — symmetric leading + trailing padding
|
||||
paddingLeft?: number; // overrides paddingX on the leading edge only
|
||||
paddingRight?: number; // overrides paddingX on the trailing edge only
|
||||
tailLength?: number; // px to extend the drawn path past the final
|
||||
// milestone, easing back to the centreline — lets
|
||||
// the line keep going as the last item scrolls in
|
||||
}
|
||||
|
||||
export interface LayoutResult {
|
||||
|
|
@ -103,6 +106,17 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
|
|||
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
|
||||
}
|
||||
|
||||
// Trailing tail: continue the path past the last milestone, easing it back
|
||||
// to the centreline so the line keeps going while the final item scrolls
|
||||
// toward the middle. Tangent stays flat at the last dot (control y = lastY).
|
||||
if (opts.tailLength && opts.tailLength > 0) {
|
||||
const lastX = itemX[itemCount - 1];
|
||||
const lastY = itemY[itemCount - 1];
|
||||
const tailEndX = lastX + opts.tailLength;
|
||||
const cx = (lastX + tailEndX) / 2;
|
||||
d += ` C ${cx} ${lastY}, ${cx} ${midY}, ${tailEndX} ${midY}`;
|
||||
}
|
||||
|
||||
return { trackWidth, pathD: d, itemX, itemY, cardSide, midY };
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +127,7 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
|
|||
* - Clamped to [0, 0.98] so the fade-to-ahead is always visible
|
||||
*/
|
||||
export function travelledStopFor(
|
||||
statuses: ReadonlyArray<'shipping' | 'in_beta' | 'exploring' | 'considering'>,
|
||||
statuses: ReadonlyArray<'shipping' | 'in_beta' | 'planned' | 'exploring' | 'considering'>,
|
||||
): number {
|
||||
if (statuses.length === 0) return 0;
|
||||
let last = -1;
|
||||
|
|
|
|||
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',
|
||||
};
|
||||
|
|
@ -293,4 +293,8 @@ const saved = Astro.url.searchParams.get('saved') === '1';
|
|||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
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);
|
||||
};
|
||||
|
|
@ -524,4 +524,10 @@ const typeColors: Record<ContributionType, string> = {
|
|||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
/* Filter + sort tab groups wrap instead of overflowing. */
|
||||
.feed-controls { flex-wrap: wrap; gap: var(--space-3); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -191,4 +191,8 @@ if (Astro.request.method === 'POST') {
|
|||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -38,4 +38,8 @@ const user = Astro.locals.user;
|
|||
.page-title { margin: 0; }
|
||||
.reading-col { max-width: var(--reading-max); }
|
||||
.lead { color: var(--on-surface-variant); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const { prev, next } = getAdjacentDispatches(d.id);
|
|||
|
||||
function closeDayLabel(closesAt: string): string {
|
||||
const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z');
|
||||
return new Intl.DateTimeFormat('en-GB', { weekday: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
|
||||
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
|
||||
}
|
||||
|
||||
function parseUtc(s: string): Date {
|
||||
|
|
@ -268,8 +268,8 @@ const bodyHtml = renderMd(d.body);
|
|||
/* ── Inline poll attached to the dispatch ──────────────────────── */
|
||||
.inline-poll {
|
||||
margin-top: var(--space-7);
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
background: rgba(185, 107, 88, 0.08);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
|
|
@ -399,6 +399,7 @@ const bodyHtml = renderMd(d.body);
|
|||
.adj-empty {} /* placeholder for missing prev/next slot */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.adjacent { grid-template-columns: 1fr; }
|
||||
.adj-next { text-align: left; align-items: flex-start; }
|
||||
.adj-next .adj-kind-pill { align-self: flex-start; }
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ function fmt(iso: string): string {
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.d-link {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
|
|
|||
|
|
@ -504,6 +504,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.hero-body { grid-template-columns: 1fr; }
|
||||
.hero-body::after { display: none; }
|
||||
.past-grid { grid-template-columns: 1fr; }
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.past-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -563,4 +563,12 @@ const innofoundarLogoExists = existsSync(join(process.cwd(), 'public/innofounder
|
|||
margin: 0;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: 0 var(--space-5) var(--space-12); }
|
||||
/* Stack the two-column rows. */
|
||||
.fenja-row,
|
||||
.ask-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.m-row { grid-template-columns: 52px 1fr; }
|
||||
.m-tags {
|
||||
grid-column: 1 / -1;
|
||||
|
|
|
|||
|
|
@ -421,4 +421,13 @@ const tiers = [
|
|||
margin: 0;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: 0 var(--space-5) var(--space-12); }
|
||||
/* Stack the label / content / tag rows. */
|
||||
.arch-row,
|
||||
.tier-row,
|
||||
.extends-item { grid-template-columns: 1fr; gap: var(--space-2); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const featuredPreview = featured ? dispatchLongPreview(featured, 720) : '';
|
|||
|
||||
function closeDayLabel(closesAt: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'long', timeZone: 'Europe/Copenhagen',
|
||||
day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen',
|
||||
}).format(parseUtc(closesAt));
|
||||
}
|
||||
|
||||
|
|
@ -110,26 +110,16 @@ const members = getAllCabMembers();
|
|||
<AppLayout title="Pulse" user={user}>
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Greeting ─────────────────────────────────────────────── -->
|
||||
<section class="cascade greeting">
|
||||
<div class="greeting-left">
|
||||
<h1 class="greeting-line">{greetingPrefix}<em class="greeting-first">{firstName}</em>.</h1>
|
||||
</div>
|
||||
{memberNumberLabel && (
|
||||
<div class="greeting-right">
|
||||
<p class="greeting-member">{memberNumberLabel}</p>
|
||||
<p class="greeting-circle">Founding circle</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<!-- ── Hero event card (--ink) ──────────────────────────────── -->
|
||||
<!-- ── Hero event card (--ink) — greeting now lives inside it ──── -->
|
||||
<section class="cascade hero-slot" aria-label="Next gathering">
|
||||
<EventHeroCard
|
||||
event={hero}
|
||||
attendees={heroAttendees}
|
||||
confirmedCount={heroConfirmedCount}
|
||||
myRsvp={heroMyRsvp}
|
||||
greetingPrefix={greetingPrefix}
|
||||
firstName={firstName}
|
||||
memberLabel={memberNumberLabel}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
|
@ -291,8 +281,7 @@ const members = getAllCabMembers();
|
|||
(96px between editorial / roadmap / council); editorial-row internal
|
||||
dispatch → 'Earlier' gap stays tight at the original 48px because
|
||||
they're the same story. */
|
||||
.greeting { margin-top: 64px; } /* below nav */
|
||||
.hero-slot { margin-top: 80px; } /* greeting → hero */
|
||||
.hero-slot { margin-top: 24px; } /* first section, below nav */
|
||||
.also-coming-up { margin-top: 18px; } /* hero → also (tight; pair) */
|
||||
.editorial-row { margin-top: 96px; } /* also → editorial */
|
||||
.roadmap-wrap { margin-top: 96px; } /* editorial → roadmap */
|
||||
|
|
@ -344,7 +333,7 @@ const members = getAllCabMembers();
|
|||
.greeting-first { font-style: italic; }
|
||||
.greeting-member {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
|
|
@ -352,14 +341,19 @@ const members = getAllCabMembers();
|
|||
}
|
||||
.greeting-circle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 32px 20px 64px; }
|
||||
.greeting { grid-template-columns: 1fr; align-items: start; }
|
||||
.greeting-right { align-items: flex-start; }
|
||||
/* "Also coming up" strip stacks and wraps instead of overflowing. */
|
||||
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
||||
.also-list { flex-wrap: wrap; gap: 12px 18px; }
|
||||
.also-title { max-width: 70vw; }
|
||||
}
|
||||
|
||||
/* ── 'Also coming up' strip (plain text on cream) ─────────────── */
|
||||
|
|
@ -403,7 +397,7 @@ const members = getAllCabMembers();
|
|||
}
|
||||
.also-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
|
|
@ -411,13 +405,13 @@ const members = getAllCabMembers();
|
|||
}
|
||||
.also-month-kind {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.also-title {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--on-surface);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -426,7 +420,7 @@ const members = getAllCabMembers();
|
|||
}
|
||||
.also-link {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
|
|
@ -452,13 +446,13 @@ const members = getAllCabMembers();
|
|||
}
|
||||
.dispatch-byline-name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.dispatch-byline-time {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.dispatch-kind-pill {
|
||||
|
|
@ -467,7 +461,7 @@ const members = getAllCabMembers();
|
|||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
|
|
@ -483,7 +477,7 @@ const members = getAllCabMembers();
|
|||
}
|
||||
.dispatch-para-lead {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
|
@ -491,7 +485,7 @@ const members = getAllCabMembers();
|
|||
margin-top: var(--space-2);
|
||||
align-self: flex-start;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
|
|
@ -511,7 +505,7 @@ const members = getAllCabMembers();
|
|||
}
|
||||
.earlier-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
|
|
@ -537,26 +531,34 @@ const members = getAllCabMembers();
|
|||
.earlier-link:hover { border-bottom: none; opacity: 0.7; }
|
||||
.earlier-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.earlier-meta {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Pulse column */
|
||||
.pulse-col { display: flex; flex-direction: column; gap: 14px; }
|
||||
/* Pulse column — tinted panel so the vote clearly stands apart from the
|
||||
dispatch beside it. Tonal colour shift, no border (per design system). */
|
||||
.pulse-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: rgba(185, 107, 88, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5) var(--space-5) var(--space-6);
|
||||
}
|
||||
.pulse-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
|
|
@ -578,7 +580,7 @@ const members = getAllCabMembers();
|
|||
}
|
||||
.pulse-status {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
|
|
@ -599,7 +601,7 @@ const members = getAllCabMembers();
|
|||
border: none;
|
||||
border-left: 2px solid rgba(0, 0, 0, 0.1);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--on-surface);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
|
@ -619,7 +621,7 @@ const members = getAllCabMembers();
|
|||
.pulse-option-letter {
|
||||
color: var(--on-surface-variant);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
|
|
@ -633,7 +635,7 @@ const members = getAllCabMembers();
|
|||
.pulse-option-pct {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
color: var(--on-surface-variant);
|
||||
|
|
@ -663,7 +665,7 @@ const members = getAllCabMembers();
|
|||
.council-title em { font-style: italic; }
|
||||
.council-all {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
|
|
@ -724,21 +726,21 @@ const members = getAllCabMembers();
|
|||
.council-tile-name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
line-height: 1.15;
|
||||
color: var(--on-surface);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.council-tile-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.council-tile-org {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const items = getAllRoadmapItems()
|
|||
<div class="roadmap-legend" aria-label="Status legend">
|
||||
<span><i style="background:#6d8c7c"></i>Shipping</span>
|
||||
<span><i style="background:#b96b58"></i>In beta</span>
|
||||
<span><i style="background:#5a6d83"></i>Planned</span>
|
||||
<span><i style="background:#b4b2a9"></i>Exploring</span>
|
||||
<span><i style="background:#d4d2c8"></i>Considering</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -240,4 +240,8 @@ const next = sorted[currentIndex - 1] ?? null;
|
|||
.post-nav-link:hover .nav-title {
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -168,4 +168,11 @@ const updates = allUpdates.sort(
|
|||
color: var(--on-surface-variant);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ── Mobile (≤720px) ────────────────────────────────────────────── */
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
/* Date stacks above the content instead of a cramped 9rem column. */
|
||||
.update-item { grid-template-columns: 1fr; gap: var(--space-2); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -289,4 +289,10 @@ const user = Astro.locals.user;
|
|||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: 0 var(--space-5) var(--space-12); }
|
||||
.bifrost-context-section { grid-template-columns: 1fr; gap: var(--space-3); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -71,14 +71,14 @@
|
|||
--text-display-md: clamp(2.5rem, 4vw, 3.5rem);
|
||||
--text-headline-lg: 2.25rem;
|
||||
--text-headline-md: 1.75rem;
|
||||
--text-headline-sm: 1.375rem;
|
||||
--text-title-lg: 1.125rem;
|
||||
--text-title-md: 1rem;
|
||||
--text-body-lg: 1.0625rem;
|
||||
--text-body-md: 1rem;
|
||||
--text-body-sm: 0.875rem;
|
||||
--text-label-md: 0.8125rem;
|
||||
--text-label-sm: 0.75rem;
|
||||
--text-headline-sm: 1.4375rem; /* 23px (was 22) */
|
||||
--text-title-lg: 1.1875rem; /* 19px (was 18) */
|
||||
--text-title-md: 1.0625rem; /* 17px (was 16) */
|
||||
--text-body-lg: 1.125rem; /* 18px (was 17) */
|
||||
--text-body-md: 1.0625rem; /* 17px (was 16) — base body */
|
||||
--text-body-sm: 0.9375rem; /* 15px (was 14) */
|
||||
--text-label-md: 0.875rem; /* 14px (was 13) */
|
||||
--text-label-sm: 0.8125rem; /* 13px (was 12) */
|
||||
|
||||
/* --- Tracking --- */
|
||||
--tracking-tight: -0.02em;
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue