Compare commits
2 commits
8bbf8568f4
...
4aaf0957dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aaf0957dd | |||
| 220f8e0290 |
9 changed files with 221 additions and 111 deletions
|
|
@ -225,16 +225,27 @@ const formAction = Astro.url.pathname + Astro.url.search;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Markdown Write/Preview toggle ────────────────────────────────────────
|
// ── 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) => {
|
document.querySelectorAll<HTMLElement>('.bs-md').forEach((root) => {
|
||||||
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
|
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
|
||||||
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
|
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
|
||||||
const preview = root.querySelector<HTMLElement>('.bs-md-preview');
|
const preview = root.querySelector<HTMLElement>('.bs-md-preview');
|
||||||
if (!input || !preview) return;
|
if (!input || !preview) return;
|
||||||
tabs.forEach((tab) => {
|
tabs.forEach((tab) => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', async () => {
|
||||||
const mode = tab.getAttribute('data-md-mode');
|
const mode = tab.getAttribute('data-md-mode');
|
||||||
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
|
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
|
||||||
if (mode === 'preview') {
|
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;
|
input.hidden = true;
|
||||||
preview.hidden = false;
|
preview.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,10 @@ const question = pulse?.question ?? '';
|
||||||
const initialOptions: string[] = pulse?.options ? [...pulse.options] : ['', ''];
|
const initialOptions: string[] = pulse?.options ? [...pulse.options] : ['', ''];
|
||||||
while (initialOptions.length < 2) initialOptions.push('');
|
while (initialOptions.length < 2) initialOptions.push('');
|
||||||
|
|
||||||
function toDatetimeLocal(v: string | null | undefined): string {
|
function toDateOnly(v: string | null | undefined): string {
|
||||||
if (!v) return '';
|
if (!v) return '';
|
||||||
const s = String(v).replace(' ', 'T');
|
const s = String(v);
|
||||||
const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
|
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||||
return m ? m[1] : '';
|
return m ? m[1] : '';
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
@ -37,8 +37,8 @@ function toDatetimeLocal(v: string | null | undefined): string {
|
||||||
<div class="bs-pulse-embed">
|
<div class="bs-pulse-embed">
|
||||||
<p class="bs-helper">
|
<p class="bs-helper">
|
||||||
Attach a pulse by filling in the question and at least two options.
|
Attach a pulse by filling in the question and at least two options.
|
||||||
Leaving the question blank means no pulse on this dispatch.
|
Leaving the question blank means no pulse on this dispatch. Pulses open
|
||||||
Pulses open when the dispatch is published and close when it's archived.
|
when the dispatch is published and close at the end of the chosen day.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="bs-field">
|
<div class="bs-field">
|
||||||
|
|
@ -76,26 +76,18 @@ function toDatetimeLocal(v: string | null | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bs-field-row">
|
|
||||||
<div class="bs-field">
|
<div class="bs-field">
|
||||||
<label class="bs-label" for="pulse_opens_at">Opens</label>
|
<label class="bs-label" for="pulse_closes_at">Closes on</label>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="date"
|
||||||
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"
|
id="pulse_closes_at"
|
||||||
name="pulse_closes_at"
|
name="pulse_closes_at"
|
||||||
class="bs-input"
|
class="bs-input"
|
||||||
value={toDatetimeLocal(pulse?.closes_at)}
|
value={toDateOnly(pulse?.closes_at)}
|
||||||
/>
|
/>
|
||||||
</div>
|
<p class="bs-helper">
|
||||||
|
The pulse closes at the end of the chosen day. Opens automatically when the
|
||||||
|
dispatch is published.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,6 @@ import type { FieldContext, Resource } from '../resource-types';
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── 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 {
|
function nowSqlite(): string {
|
||||||
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +35,12 @@ function plusDaysSqlite(days: number): string {
|
||||||
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
|
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. */
|
/** Read pulse_* fields out of FormData. Returns null when no question was provided. */
|
||||||
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
|
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
|
||||||
const question = String(formData.get('pulse_question') ?? '').trim();
|
const question = String(formData.get('pulse_question') ?? '').trim();
|
||||||
|
|
@ -51,10 +51,14 @@ function extractPulseFromFormData(formData: FormData): DispatchPollInput | null
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
if (options.length < 2) return null;
|
if (options.length < 2) return null;
|
||||||
const opens_at =
|
|
||||||
toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite();
|
// Opens always = now (or the dispatch's publish moment). closes_at comes
|
||||||
const closes_at =
|
// from a date input and snaps to 23:59:59 on the chosen day; missing →
|
||||||
toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14);
|
// 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);
|
||||||
|
|
||||||
return { question, options, opens_at, closes_at };
|
return { question, options, opens_at, closes_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
type EventKind,
|
type EventKind,
|
||||||
} from '../../lib/db';
|
} from '../../lib/db';
|
||||||
import { eventKindLabel } from '../../lib/format';
|
import { eventKindLabel } from '../../lib/format';
|
||||||
|
import { fmtDateTime } from '../../lib/markdown';
|
||||||
import type { Resource } from '../resource-types';
|
import type { Resource } from '../resource-types';
|
||||||
|
|
||||||
function slugify(s: string): string {
|
function slugify(s: string): string {
|
||||||
|
|
@ -42,6 +43,30 @@ function toSqliteDatetime(s: string): string {
|
||||||
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
|
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> = {
|
export const eventsResource: Resource<Event> = {
|
||||||
key: 'events',
|
key: 'events',
|
||||||
label: 'Events',
|
label: 'Events',
|
||||||
|
|
@ -79,10 +104,11 @@ export const eventsResource: Resource<Event> = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'starts_at',
|
key: 'starts_at',
|
||||||
label: 'Starts',
|
label: 'Date',
|
||||||
kind: 'relative-date',
|
width: '180px',
|
||||||
width: '110px',
|
render: (item) => ({
|
||||||
emptyFallback: '—',
|
title: item.starts_at ? fmtDateTime(item.starts_at) : '—',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'capacity',
|
key: 'capacity',
|
||||||
|
|
@ -169,13 +195,6 @@ export const eventsResource: Resource<Event> = {
|
||||||
maxLength: 200,
|
maxLength: 200,
|
||||||
helperText: 'Free-form note about who the event is for (e.g. "Council members only").',
|
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',
|
key: 'action_label',
|
||||||
label: 'Action button label',
|
label: 'Action button label',
|
||||||
|
|
@ -206,18 +225,20 @@ export const eventsResource: Resource<Event> = {
|
||||||
create: (data, ctx) => {
|
create: (data, ctx) => {
|
||||||
const rawSlug = ((data.slug as string) ?? '').trim();
|
const rawSlug = ((data.slug as string) ?? '').trim();
|
||||||
const slug = uniqueEventSlug(rawSlug ? slugify(rawSlug) : slugify(String(data.title)));
|
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({
|
return createEvent({
|
||||||
slug,
|
slug,
|
||||||
title: String(data.title),
|
title: String(data.title),
|
||||||
kind: data.kind as EventKind,
|
kind: data.kind as EventKind,
|
||||||
description: String(data.description),
|
description: String(data.description),
|
||||||
location: String(data.location),
|
location: String(data.location),
|
||||||
starts_at: toSqliteDatetime(String(data.starts_at)),
|
starts_at: startsAt,
|
||||||
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
|
ends_at: endsAt,
|
||||||
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
||||||
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
||||||
audience: ((data.audience as string) ?? '').trim() || null,
|
audience: ((data.audience as string) ?? '').trim() || null,
|
||||||
duration_label: ((data.duration_label as string) ?? '').trim() || null,
|
duration_label: computeDurationLabel(startsAt, endsAt),
|
||||||
action_label: ((data.action_label as string) ?? '').trim() || null,
|
action_label: ((data.action_label as string) ?? '').trim() || null,
|
||||||
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
||||||
created_by: ctx.user.id,
|
created_by: ctx.user.id,
|
||||||
|
|
@ -225,17 +246,19 @@ export const eventsResource: Resource<Event> = {
|
||||||
},
|
},
|
||||||
|
|
||||||
update: (id, data) => {
|
update: (id, data) => {
|
||||||
|
const startsAt = toSqliteDatetime(String(data.starts_at));
|
||||||
|
const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null;
|
||||||
updateEvent(id, {
|
updateEvent(id, {
|
||||||
title: String(data.title),
|
title: String(data.title),
|
||||||
kind: data.kind as EventKind,
|
kind: data.kind as EventKind,
|
||||||
description: String(data.description),
|
description: String(data.description),
|
||||||
location: String(data.location),
|
location: String(data.location),
|
||||||
starts_at: toSqliteDatetime(String(data.starts_at)),
|
starts_at: startsAt,
|
||||||
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
|
ends_at: endsAt,
|
||||||
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
||||||
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
||||||
audience: ((data.audience as string) ?? '').trim() || null,
|
audience: ((data.audience as string) ?? '').trim() || null,
|
||||||
duration_label: ((data.duration_label as string) ?? '').trim() || null,
|
duration_label: computeDurationLabel(startsAt, endsAt),
|
||||||
action_label: ((data.action_label as string) ?? '').trim() || null,
|
action_label: ((data.action_label as string) ?? '').trim() || null,
|
||||||
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,17 @@ interface Props {
|
||||||
|
|
||||||
const { items, viewportWidth = 1100 } = Astro.props;
|
const { items, viewportWidth = 1100 } = Astro.props;
|
||||||
|
|
||||||
const layout = computeRouteLayout({ itemCount: items.length, viewportWidth });
|
// 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 travelledStop = travelledStopFor(items.map(i => i.status));
|
const travelledStop = travelledStopFor(items.map(i => i.status));
|
||||||
|
|
||||||
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
const STATUS_LABEL: Record<RoadmapStatus, string> = {
|
||||||
|
|
@ -158,6 +168,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
// amplitude doesn't change with viewport, only the horizontal spread).
|
// amplitude doesn't change with viewport, only the horizontal spread).
|
||||||
const MIN_SPACING = 320;
|
const MIN_SPACING = 320;
|
||||||
const PADDING_X = 60;
|
const PADDING_X = 60;
|
||||||
|
const CONTENT_MAX = 1152; // matches --content-max (72rem)
|
||||||
|
|
||||||
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
||||||
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
||||||
|
|
@ -180,14 +191,17 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
const targetUsableWidth = vw * 0.80;
|
const targetUsableWidth = vw * 0.80;
|
||||||
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
|
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
|
||||||
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
|
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
|
||||||
const trackWidth = usableWidth + PADDING_X * 2;
|
// 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 itemX: number[] = [];
|
const itemX: number[] = [];
|
||||||
for (let i = 0; i < itemCount; i += 1) {
|
for (let i = 0; i < itemCount; i += 1) {
|
||||||
itemX.push(
|
itemX.push(
|
||||||
itemCount === 1
|
itemCount === 1
|
||||||
? PADDING_X + usableWidth / 2
|
? paddingLeft + usableWidth / 2
|
||||||
: PADDING_X + (i / (itemCount - 1)) * usableWidth,
|
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,16 +368,12 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
|
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial mount: recompute with the real viewport, then scroll the
|
// Initial mount: recompute with the real viewport, then anchor at the
|
||||||
// 'you are here' milestone roughly 25% from the left.
|
// 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.)
|
||||||
recompute();
|
recompute();
|
||||||
const initialX = milestones.find(m => m.querySelector('.rr-dot.rr-current'));
|
scroll.scrollLeft = 0;
|
||||||
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);
|
setTimeout(updateNav, 50);
|
||||||
updateNav();
|
updateNav();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -716,6 +716,15 @@ export function createRoadmapItem(data: {
|
||||||
metadata_text?: string | null;
|
metadata_text?: string | null;
|
||||||
}): number {
|
}): number {
|
||||||
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
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(`
|
const r = db.prepare(`
|
||||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||||
VALUES (?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?)
|
||||||
|
|
@ -724,11 +733,12 @@ export function createRoadmapItem(data: {
|
||||||
data.description,
|
data.description,
|
||||||
data.status,
|
data.status,
|
||||||
data.target ?? null,
|
data.target ?? null,
|
||||||
data.display_order ?? 0,
|
requestedOrder,
|
||||||
shipped_at,
|
shipped_at,
|
||||||
data.metadata_text ?? null,
|
data.metadata_text ?? null,
|
||||||
);
|
);
|
||||||
return Number(r.lastInsertRowid);
|
return Number(r.lastInsertRowid);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -743,8 +753,8 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
display_order: number;
|
display_order: number;
|
||||||
metadata_text?: string | null;
|
metadata_text?: string | null;
|
||||||
}): { shippedNow: boolean } {
|
}): { shippedNow: boolean } {
|
||||||
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
|
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
|
||||||
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
|
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
|
||||||
if (!current) throw new Error(`Roadmap item ${id} not found`);
|
if (!current) throw new Error(`Roadmap item ${id} not found`);
|
||||||
|
|
||||||
const shippedNow = data.status === 'shipping' && current.shipped_at === null;
|
const shippedNow = data.status === 'shipping' && current.shipped_at === null;
|
||||||
|
|
@ -752,6 +762,22 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||||
: current.shipped_at;
|
: 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(`
|
db.prepare(`
|
||||||
UPDATE roadmap_items
|
UPDATE roadmap_items
|
||||||
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
||||||
|
|
@ -760,10 +786,21 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, 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 {
|
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);
|
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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
|
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ export interface LayoutOpts {
|
||||||
minSpacingX?: number; // default 320
|
minSpacingX?: number; // default 320
|
||||||
trackHeight?: number; // default 460
|
trackHeight?: number; // default 460
|
||||||
amplitude?: number; // default 120
|
amplitude?: number; // default 120
|
||||||
paddingX?: number; // default 60
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LayoutResult {
|
export interface LayoutResult {
|
||||||
|
|
@ -35,7 +37,9 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
|
||||||
const minSpacing = opts.minSpacingX ?? 320;
|
const minSpacing = opts.minSpacingX ?? 320;
|
||||||
const trackHeight = opts.trackHeight ?? 420;
|
const trackHeight = opts.trackHeight ?? 420;
|
||||||
const amplitude = opts.amplitude ?? 120;
|
const amplitude = opts.amplitude ?? 120;
|
||||||
const padding = opts.paddingX ?? 60;
|
const paddingDef = opts.paddingX ?? 60;
|
||||||
|
const paddingL = opts.paddingLeft ?? paddingDef;
|
||||||
|
const paddingR = opts.paddingRight ?? paddingDef;
|
||||||
const midY = trackHeight / 2;
|
const midY = trackHeight / 2;
|
||||||
|
|
||||||
const itemCount = Math.max(0, opts.itemCount);
|
const itemCount = Math.max(0, opts.itemCount);
|
||||||
|
|
@ -57,12 +61,12 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
|
||||||
const targetUsableWidth = opts.viewportWidth * 0.80;
|
const targetUsableWidth = opts.viewportWidth * 0.80;
|
||||||
const dataDrivenWidth = (itemCount - 1) * minSpacing;
|
const dataDrivenWidth = (itemCount - 1) * minSpacing;
|
||||||
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
|
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
|
||||||
const trackWidth = usableWidth + padding * 2;
|
const trackWidth = paddingL + usableWidth + paddingR;
|
||||||
|
|
||||||
const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
|
const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
|
||||||
itemCount === 1
|
itemCount === 1
|
||||||
? padding + usableWidth / 2
|
? paddingL + usableWidth / 2
|
||||||
: padding + (i / (itemCount - 1)) * usableWidth,
|
: paddingL + (i / (itemCount - 1)) * usableWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
// First item on the centreline; subsequent items alternate up/down with
|
// First item on the centreline; subsequent items alternate up/down with
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ const bodyHtml = renderMd(d.body);
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||||
max-width: 720px;
|
max-width: 1080px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import Avatar from '../components/Avatar.astro';
|
||||||
import EventHeroCard from '../components/EventHeroCard.astro';
|
import EventHeroCard from '../components/EventHeroCard.astro';
|
||||||
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
|
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
|
||||||
import {
|
import {
|
||||||
getUpcomingEvents, getEventBySlug, getEventAttendees,
|
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
|
||||||
getUserRsvp, setEventRsvp, recordActivity,
|
getUserRsvp, setEventRsvp, recordActivity,
|
||||||
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
|
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
|
||||||
getAllCabMembers, getPulseById, castOrChangeVote,
|
getAllCabMembers, getPulseById, castOrChangeVote,
|
||||||
|
|
@ -57,9 +57,19 @@ const memberNumberLabel = user.member_number != null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// ── Events ─────────────────────────────────────────────────────────
|
// ── 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 upcoming = getUpcomingEvents(20);
|
||||||
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? upcoming[0] ?? null;
|
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? upcoming[0] ?? null;
|
||||||
const comingUp = upcoming.filter(e => e.id !== hero?.id).slice(0, 4);
|
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;
|
||||||
|
|
||||||
function parseUtc(s: string): Date {
|
function parseUtc(s: string): Date {
|
||||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||||
|
|
@ -123,21 +133,32 @@ const members = getAllCabMembers();
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── 'Also coming up' strip (plain text on cream) ─────────── -->
|
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
|
||||||
<section class="cascade also-coming-up" aria-label="Also coming up">
|
<section class="cascade also-coming-up" aria-label="Surrounding events">
|
||||||
<div class="also-list">
|
<div class="also-list">
|
||||||
{comingUp.slice(0, 2).map((ev, i) => (
|
{previousEvent && (
|
||||||
<>
|
|
||||||
{i > 0 && <span class="also-divider" aria-hidden="true"></span>}
|
|
||||||
<div class="also-item">
|
<div class="also-item">
|
||||||
<span class="also-day">{dayNum(ev.starts_at)}</span>
|
<span class="also-day">{dayNum(previousEvent.starts_at)}</span>
|
||||||
<div class="also-meta-col">
|
<div class="also-meta-col">
|
||||||
<span class="also-month-kind">{monthShort(ev.starts_at)} · {eventKindLabel(ev.kind).toUpperCase()}</span>
|
<span class="also-eyebrow">Previous</span>
|
||||||
<span class="also-title">{ev.title}</span>
|
<span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span>
|
||||||
|
<span class="also-title">{previousEvent.title}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<a href="/events" class="also-link">All gatherings →</a>
|
<a href="/events" class="also-link">All gatherings →</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -380,6 +401,14 @@ const members = getAllCabMembers();
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-width: 0;
|
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 {
|
.also-month-kind {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue