Compare commits
No commits in common. "4aaf0957dd1773526b91e5207173a621d413687c" and "8bbf8568f4a775f12a59f7b1b394860256ebe4df" have entirely different histories.
4aaf0957dd
...
8bbf8568f4
9 changed files with 111 additions and 221 deletions
|
|
@ -225,27 +225,16 @@ const formAction = Astro.url.pathname + Astro.url.search;
|
|||
});
|
||||
|
||||
// ── Markdown Write/Preview toggle ────────────────────────────────────────
|
||||
// Re-renders preview from the textarea's *current* value on every toggle.
|
||||
// The server-side initial render is only valid for the seed value — once
|
||||
// the admin types, only client-side rendering reflects what's there.
|
||||
document.querySelectorAll<HTMLElement>('.bs-md').forEach((root) => {
|
||||
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
|
||||
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
|
||||
const preview = root.querySelector<HTMLElement>('.bs-md-preview');
|
||||
if (!input || !preview) return;
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', async () => {
|
||||
tab.addEventListener('click', () => {
|
||||
const mode = tab.getAttribute('data-md-mode');
|
||||
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
|
||||
if (mode === 'preview') {
|
||||
const value = input.value;
|
||||
if (value.trim() === '') {
|
||||
preview.innerHTML = '<p class="bs-md-empty">Nothing to preview yet.</p>';
|
||||
} else {
|
||||
const { marked } = await import('marked');
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
preview.innerHTML = await marked.parse(value);
|
||||
}
|
||||
input.hidden = true;
|
||||
preview.hidden = false;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ const question = pulse?.question ?? '';
|
|||
const initialOptions: string[] = pulse?.options ? [...pulse.options] : ['', ''];
|
||||
while (initialOptions.length < 2) initialOptions.push('');
|
||||
|
||||
function toDateOnly(v: string | null | undefined): string {
|
||||
function toDatetimeLocal(v: string | null | undefined): string {
|
||||
if (!v) return '';
|
||||
const s = String(v);
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
const s = String(v).replace(' ', 'T');
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
---
|
||||
|
|
@ -37,8 +37,8 @@ function toDateOnly(v: string | null | undefined): string {
|
|||
<div class="bs-pulse-embed">
|
||||
<p class="bs-helper">
|
||||
Attach a pulse by filling in the question and at least two options.
|
||||
Leaving the question blank means no pulse on this dispatch. Pulses open
|
||||
when the dispatch is published and close at the end of the chosen day.
|
||||
Leaving the question blank means no pulse on this dispatch.
|
||||
Pulses open when the dispatch is published and close when it's archived.
|
||||
</p>
|
||||
|
||||
<div class="bs-field">
|
||||
|
|
@ -76,18 +76,26 @@ function toDateOnly(v: string | null | undefined): string {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bs-field">
|
||||
<label class="bs-label" for="pulse_closes_at">Closes on</label>
|
||||
<input
|
||||
type="date"
|
||||
id="pulse_closes_at"
|
||||
name="pulse_closes_at"
|
||||
class="bs-input"
|
||||
value={toDateOnly(pulse?.closes_at)}
|
||||
/>
|
||||
<p class="bs-helper">
|
||||
The pulse closes at the end of the chosen day. Opens automatically when the
|
||||
dispatch is published.
|
||||
</p>
|
||||
<div class="bs-field-row">
|
||||
<div class="bs-field">
|
||||
<label class="bs-label" for="pulse_opens_at">Opens</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="pulse_opens_at"
|
||||
name="pulse_opens_at"
|
||||
class="bs-input"
|
||||
value={toDatetimeLocal(pulse?.opens_at)}
|
||||
/>
|
||||
</div>
|
||||
<div class="bs-field">
|
||||
<label class="bs-label" for="pulse_closes_at">Closes</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="pulse_closes_at"
|
||||
name="pulse_closes_at"
|
||||
class="bs-input"
|
||||
value={toDatetimeLocal(pulse?.closes_at)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ import type { FieldContext, Resource } from '../resource-types';
|
|||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** "YYYY-MM-DDTHH:mm" (datetime-local) → "YYYY-MM-DD HH:mm:ss" (SQLite). */
|
||||
function toSqliteDatetime(s: string): string {
|
||||
if (!s) return '';
|
||||
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
|
||||
}
|
||||
|
||||
function nowSqlite(): string {
|
||||
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
|
@ -35,12 +41,6 @@ function plusDaysSqlite(days: number): string {
|
|||
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
/** "YYYY-MM-DD" → "YYYY-MM-DD 23:59:59" (end-of-day in DB local time). */
|
||||
function endOfDaySqlite(dateStr: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return '';
|
||||
return `${dateStr} 23:59:59`;
|
||||
}
|
||||
|
||||
/** Read pulse_* fields out of FormData. Returns null when no question was provided. */
|
||||
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
|
||||
const question = String(formData.get('pulse_question') ?? '').trim();
|
||||
|
|
@ -51,14 +51,10 @@ function extractPulseFromFormData(formData: FormData): DispatchPollInput | null
|
|||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
if (options.length < 2) return null;
|
||||
|
||||
// Opens always = now (or the dispatch's publish moment). closes_at comes
|
||||
// from a date input and snaps to 23:59:59 on the chosen day; missing →
|
||||
// default to 14 days out.
|
||||
const opens_at = nowSqlite();
|
||||
const closesRaw = String(formData.get('pulse_closes_at') ?? '').trim();
|
||||
const closes_at = endOfDaySqlite(closesRaw) || plusDaysSqlite(14);
|
||||
|
||||
const opens_at =
|
||||
toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite();
|
||||
const closes_at =
|
||||
toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14);
|
||||
return { question, options, opens_at, closes_at };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
type EventKind,
|
||||
} from '../../lib/db';
|
||||
import { eventKindLabel } from '../../lib/format';
|
||||
import { fmtDateTime } from '../../lib/markdown';
|
||||
import type { Resource } from '../resource-types';
|
||||
|
||||
function slugify(s: string): string {
|
||||
|
|
@ -43,30 +42,6 @@ function toSqliteDatetime(s: string): string {
|
|||
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
|
||||
}
|
||||
|
||||
/** Compute a human duration label from start + end SQLite datetimes.
|
||||
* Returns null when ends_at is missing (open-ended event). */
|
||||
function computeDurationLabel(startsAt: string, endsAt: string | null): string | null {
|
||||
if (!endsAt) return null;
|
||||
const start = new Date(startsAt.replace(' ', 'T') + 'Z').getTime();
|
||||
const end = new Date(endsAt.replace(' ', 'T') + 'Z').getTime();
|
||||
const ms = end - start;
|
||||
if (!Number.isFinite(ms) || ms <= 0) return null;
|
||||
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 90) return `${minutes} min`;
|
||||
|
||||
const hours = ms / 3_600_000;
|
||||
if (hours < 4) {
|
||||
const rounded = Math.round(hours * 2) / 2; // nearest half hour
|
||||
return Number.isInteger(rounded) ? `${rounded} hr` : `${rounded} hr`;
|
||||
}
|
||||
if (hours < 7) return 'Half day';
|
||||
if (hours < 10) return 'Full day';
|
||||
|
||||
const days = Math.round(hours / 24);
|
||||
return days === 1 ? '1 day' : `${days} days`;
|
||||
}
|
||||
|
||||
export const eventsResource: Resource<Event> = {
|
||||
key: 'events',
|
||||
label: 'Events',
|
||||
|
|
@ -104,11 +79,10 @@ export const eventsResource: Resource<Event> = {
|
|||
},
|
||||
{
|
||||
key: 'starts_at',
|
||||
label: 'Date',
|
||||
width: '180px',
|
||||
render: (item) => ({
|
||||
title: item.starts_at ? fmtDateTime(item.starts_at) : '—',
|
||||
}),
|
||||
label: 'Starts',
|
||||
kind: 'relative-date',
|
||||
width: '110px',
|
||||
emptyFallback: '—',
|
||||
},
|
||||
{
|
||||
key: 'capacity',
|
||||
|
|
@ -195,6 +169,13 @@ export const eventsResource: Resource<Event> = {
|
|||
maxLength: 200,
|
||||
helperText: 'Free-form note about who the event is for (e.g. "Council members only").',
|
||||
},
|
||||
{
|
||||
key: 'duration_label',
|
||||
label: 'Duration label',
|
||||
kind: 'text',
|
||||
maxLength: 40,
|
||||
helperText: 'Optional display label like "90 min" or "Half day".',
|
||||
},
|
||||
{
|
||||
key: 'action_label',
|
||||
label: 'Action button label',
|
||||
|
|
@ -225,20 +206,18 @@ export const eventsResource: Resource<Event> = {
|
|||
create: (data, ctx) => {
|
||||
const rawSlug = ((data.slug as string) ?? '').trim();
|
||||
const slug = uniqueEventSlug(rawSlug ? slugify(rawSlug) : slugify(String(data.title)));
|
||||
const startsAt = toSqliteDatetime(String(data.starts_at));
|
||||
const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null;
|
||||
return createEvent({
|
||||
slug,
|
||||
title: String(data.title),
|
||||
kind: data.kind as EventKind,
|
||||
description: String(data.description),
|
||||
location: String(data.location),
|
||||
starts_at: startsAt,
|
||||
ends_at: endsAt,
|
||||
starts_at: toSqliteDatetime(String(data.starts_at)),
|
||||
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
|
||||
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
||||
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
||||
audience: ((data.audience as string) ?? '').trim() || null,
|
||||
duration_label: computeDurationLabel(startsAt, endsAt),
|
||||
duration_label: ((data.duration_label as string) ?? '').trim() || null,
|
||||
action_label: ((data.action_label as string) ?? '').trim() || null,
|
||||
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
||||
created_by: ctx.user.id,
|
||||
|
|
@ -246,19 +225,17 @@ export const eventsResource: Resource<Event> = {
|
|||
},
|
||||
|
||||
update: (id, data) => {
|
||||
const startsAt = toSqliteDatetime(String(data.starts_at));
|
||||
const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null;
|
||||
updateEvent(id, {
|
||||
title: String(data.title),
|
||||
kind: data.kind as EventKind,
|
||||
description: String(data.description),
|
||||
location: String(data.location),
|
||||
starts_at: startsAt,
|
||||
ends_at: endsAt,
|
||||
starts_at: toSqliteDatetime(String(data.starts_at)),
|
||||
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
|
||||
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
||||
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
||||
audience: ((data.audience as string) ?? '').trim() || null,
|
||||
duration_label: computeDurationLabel(startsAt, endsAt),
|
||||
duration_label: ((data.duration_label as string) ?? '').trim() || null,
|
||||
action_label: ((data.action_label as string) ?? '').trim() || null,
|
||||
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,17 +9,7 @@ interface Props {
|
|||
|
||||
const { items, viewportWidth = 1100 } = Astro.props;
|
||||
|
||||
// Align the first milestone with the left edge of the page's content column
|
||||
// (matches the LatestDispatchBanner below). --content-max is 72rem = 1152px.
|
||||
const CONTENT_MAX = 1152;
|
||||
const DEFAULT_PADDING = 60;
|
||||
const paddingLeft = Math.max(DEFAULT_PADDING, (viewportWidth - CONTENT_MAX) / 2);
|
||||
|
||||
const layout = computeRouteLayout({
|
||||
itemCount: items.length,
|
||||
viewportWidth,
|
||||
paddingLeft,
|
||||
});
|
||||
const layout = computeRouteLayout({ itemCount: items.length, viewportWidth });
|
||||
const travelledStop = travelledStopFor(items.map(i => i.status));
|
||||
|
||||
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
||||
|
|
@ -168,7 +158,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
// amplitude doesn't change with viewport, only the horizontal spread).
|
||||
const MIN_SPACING = 320;
|
||||
const PADDING_X = 60;
|
||||
const CONTENT_MAX = 1152; // matches --content-max (72rem)
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
||||
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
||||
|
|
@ -191,17 +180,14 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
const targetUsableWidth = vw * 0.80;
|
||||
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
|
||||
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
|
||||
// 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;
|
||||
const trackWidth = usableWidth + PADDING_X * 2;
|
||||
|
||||
const itemX: number[] = [];
|
||||
for (let i = 0; i < itemCount; i += 1) {
|
||||
itemX.push(
|
||||
itemCount === 1
|
||||
? paddingLeft + usableWidth / 2
|
||||
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
|
||||
? PADDING_X + usableWidth / 2
|
||||
: PADDING_X + (i / (itemCount - 1)) * usableWidth,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -368,12 +354,16 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
|
||||
});
|
||||
|
||||
// Initial mount: recompute with the real viewport, then anchor at the
|
||||
// start so the first milestone aligns with the content-column left edge.
|
||||
// (The "you are here" highlight on the most-recent shipping milestone is
|
||||
// still visible — but it's no longer the scroll anchor.)
|
||||
// Initial mount: recompute with the real viewport, then scroll the
|
||||
// 'you are here' milestone roughly 25% from the left.
|
||||
recompute();
|
||||
scroll.scrollLeft = 0;
|
||||
const initialX = milestones.find(m => m.querySelector('.rr-dot.rr-current'));
|
||||
if (initialX) {
|
||||
const x = parseFloat(initialX.style.left) || 0;
|
||||
const max = scroll.scrollWidth - scroll.clientWidth;
|
||||
const target = Math.max(0, Math.min(max, x - scroll.clientWidth * 0.25));
|
||||
scroll.scrollLeft = target;
|
||||
}
|
||||
setTimeout(updateNav, 50);
|
||||
updateNav();
|
||||
|
||||
|
|
|
|||
|
|
@ -716,29 +716,19 @@ export function createRoadmapItem(data: {
|
|||
metadata_text?: string | null;
|
||||
}): number {
|
||||
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
||||
const requestedOrder = data.display_order ?? 0;
|
||||
|
||||
return db.transaction(() => {
|
||||
// Cascade: insert at position N shifts every existing item at or after N
|
||||
// down by one, keeping the order dense.
|
||||
db.prepare(
|
||||
'UPDATE roadmap_items SET display_order = display_order + 1 WHERE display_order >= ?'
|
||||
).run(requestedOrder);
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
data.title,
|
||||
data.description,
|
||||
data.status,
|
||||
data.target ?? null,
|
||||
requestedOrder,
|
||||
shipped_at,
|
||||
data.metadata_text ?? null,
|
||||
);
|
||||
return Number(r.lastInsertRowid);
|
||||
})();
|
||||
const r = db.prepare(`
|
||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
data.title,
|
||||
data.description,
|
||||
data.status,
|
||||
data.target ?? null,
|
||||
data.display_order ?? 0,
|
||||
shipped_at,
|
||||
data.metadata_text ?? null,
|
||||
);
|
||||
return Number(r.lastInsertRowid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -753,8 +743,8 @@ export function updateRoadmapItem(id: number, data: {
|
|||
display_order: number;
|
||||
metadata_text?: string | null;
|
||||
}): { shippedNow: boolean } {
|
||||
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
|
||||
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
|
||||
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
|
||||
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
|
||||
if (!current) throw new Error(`Roadmap item ${id} not found`);
|
||||
|
||||
const shippedNow = data.status === 'shipping' && current.shipped_at === null;
|
||||
|
|
@ -762,45 +752,18 @@ export function updateRoadmapItem(id: number, data: {
|
|||
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
: current.shipped_at;
|
||||
|
||||
return db.transaction(() => {
|
||||
// Cascade neighbours when display_order changes.
|
||||
// Moving forward (A → B, B > A): rows in (A, B] shift down by 1.
|
||||
// Moving back (A → B, B < A): rows in [B, A) shift up by 1.
|
||||
const from = current.display_order;
|
||||
const to = data.display_order;
|
||||
if (to > from) {
|
||||
db.prepare(
|
||||
'UPDATE roadmap_items SET display_order = display_order - 1 WHERE id != ? AND display_order > ? AND display_order <= ?'
|
||||
).run(id, from, to);
|
||||
} else if (to < from) {
|
||||
db.prepare(
|
||||
'UPDATE roadmap_items SET display_order = display_order + 1 WHERE id != ? AND display_order >= ? AND display_order < ?'
|
||||
).run(id, to, from);
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE roadmap_items
|
||||
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
||||
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE roadmap_items
|
||||
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
||||
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
|
||||
|
||||
return { shippedNow };
|
||||
})();
|
||||
return { shippedNow };
|
||||
}
|
||||
|
||||
export function deleteRoadmapItem(id: number): void {
|
||||
db.transaction(() => {
|
||||
const row = db.prepare('SELECT display_order FROM roadmap_items WHERE id = ?')
|
||||
.get(id) as { display_order: number } | undefined;
|
||||
db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id);
|
||||
if (row) {
|
||||
// Cascade: every row after the deleted slot shifts up by 1.
|
||||
db.prepare(
|
||||
'UPDATE roadmap_items SET display_order = display_order - 1 WHERE display_order > ?'
|
||||
).run(row.display_order);
|
||||
}
|
||||
})();
|
||||
db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@ export interface LayoutOpts {
|
|||
minSpacingX?: number; // default 320
|
||||
trackHeight?: number; // default 460
|
||||
amplitude?: number; // default 120
|
||||
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
|
||||
paddingX?: number; // default 60
|
||||
}
|
||||
|
||||
export interface LayoutResult {
|
||||
|
|
@ -37,9 +35,7 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
|
|||
const minSpacing = opts.minSpacingX ?? 320;
|
||||
const trackHeight = opts.trackHeight ?? 420;
|
||||
const amplitude = opts.amplitude ?? 120;
|
||||
const paddingDef = opts.paddingX ?? 60;
|
||||
const paddingL = opts.paddingLeft ?? paddingDef;
|
||||
const paddingR = opts.paddingRight ?? paddingDef;
|
||||
const padding = opts.paddingX ?? 60;
|
||||
const midY = trackHeight / 2;
|
||||
|
||||
const itemCount = Math.max(0, opts.itemCount);
|
||||
|
|
@ -61,12 +57,12 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
|
|||
const targetUsableWidth = opts.viewportWidth * 0.80;
|
||||
const dataDrivenWidth = (itemCount - 1) * minSpacing;
|
||||
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
|
||||
const trackWidth = paddingL + usableWidth + paddingR;
|
||||
const trackWidth = usableWidth + padding * 2;
|
||||
|
||||
const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
|
||||
itemCount === 1
|
||||
? paddingL + usableWidth / 2
|
||||
: paddingL + (i / (itemCount - 1)) * usableWidth,
|
||||
? padding + usableWidth / 2
|
||||
: padding + (i / (itemCount - 1)) * usableWidth,
|
||||
);
|
||||
|
||||
// First item on the centreline; subsequent items alternate up/down with
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ const bodyHtml = renderMd(d.body);
|
|||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: 1080px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Avatar from '../components/Avatar.astro';
|
|||
import EventHeroCard from '../components/EventHeroCard.astro';
|
||||
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
|
||||
import {
|
||||
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
|
||||
getUpcomingEvents, getEventBySlug, getEventAttendees,
|
||||
getUserRsvp, setEventRsvp, recordActivity,
|
||||
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
|
||||
getAllCabMembers, getPulseById, castOrChangeVote,
|
||||
|
|
@ -57,19 +57,9 @@ const memberNumberLabel = user.member_number != null
|
|||
: null;
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────
|
||||
// Hero = next upcoming non-office-hours event (falls back to first upcoming
|
||||
// of any kind if everything in the queue is office hours).
|
||||
// Below: the most recent past event ("Previous") and the next non-office-hours
|
||||
// upcoming event AFTER the hero ("Upcoming").
|
||||
const upcoming = getUpcomingEvents(20);
|
||||
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? upcoming[0] ?? null;
|
||||
const upcomingAfterHero = upcoming
|
||||
.filter(e => e.id !== hero?.id && e.kind !== 'office_hours')[0]
|
||||
?? upcoming.filter(e => e.id !== hero?.id)[0]
|
||||
?? null;
|
||||
const previousEvent = getPastEvents(20).find(e => e.kind !== 'office_hours')
|
||||
?? getPastEvents(1)[0]
|
||||
?? null;
|
||||
const comingUp = upcoming.filter(e => e.id !== hero?.id).slice(0, 4);
|
||||
|
||||
function parseUtc(s: string): Date {
|
||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||
|
|
@ -133,32 +123,21 @@ const members = getAllCabMembers();
|
|||
/>
|
||||
</section>
|
||||
|
||||
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
|
||||
<section class="cascade also-coming-up" aria-label="Surrounding events">
|
||||
<!-- ── 'Also coming up' strip (plain text on cream) ─────────── -->
|
||||
<section class="cascade also-coming-up" aria-label="Also coming up">
|
||||
<div class="also-list">
|
||||
{previousEvent && (
|
||||
<div class="also-item">
|
||||
<span class="also-day">{dayNum(previousEvent.starts_at)}</span>
|
||||
<div class="also-meta-col">
|
||||
<span class="also-eyebrow">Previous</span>
|
||||
<span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span>
|
||||
<span class="also-title">{previousEvent.title}</span>
|
||||
{comingUp.slice(0, 2).map((ev, i) => (
|
||||
<>
|
||||
{i > 0 && <span class="also-divider" aria-hidden="true"></span>}
|
||||
<div class="also-item">
|
||||
<span class="also-day">{dayNum(ev.starts_at)}</span>
|
||||
<div class="also-meta-col">
|
||||
<span class="also-month-kind">{monthShort(ev.starts_at)} · {eventKindLabel(ev.kind).toUpperCase()}</span>
|
||||
<span class="also-title">{ev.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previousEvent && upcomingAfterHero && (
|
||||
<span class="also-divider" aria-hidden="true"></span>
|
||||
)}
|
||||
{upcomingAfterHero && (
|
||||
<div class="also-item">
|
||||
<span class="also-day">{dayNum(upcomingAfterHero.starts_at)}</span>
|
||||
<div class="also-meta-col">
|
||||
<span class="also-eyebrow">Upcoming</span>
|
||||
<span class="also-month-kind">{monthShort(upcomingAfterHero.starts_at)} · {eventKindLabel(upcomingAfterHero.kind).toUpperCase()}</span>
|
||||
<span class="also-title">{upcomingAfterHero.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<a href="/events" class="also-link">All gatherings →</a>
|
||||
</section>
|
||||
|
|
@ -401,14 +380,6 @@ const members = getAllCabMembers();
|
|||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.also-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
font-weight: 600;
|
||||
}
|
||||
.also-month-kind {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue