1. Markdown preview in the admin edit panel now re-renders from the textarea's current value on every toggle (dynamic-imports marked on the client). Previously the panel showed the server-rendered seed value forever, so new dispatches always previewed empty. 2. Pulse sub-form drops the opens_at field (opens on dispatch publish automatically) and changes closes_at to a date input — the chosen day is treated as end-of-day in the DB. 3. /dispatches/[slug] reading width widened 50% (720 → 1080px). 4. Roadmap display_order cascades on insert / update / delete: inserting at N bumps N..end up by 1, deleting N pulls N+1..end down by 1, moving from A to B shifts the intermediate range by 1 in the appropriate direction. Order stays dense — no gaps, no collisions. All three transitions run in a transaction. 5. /roadmap always anchors at scrollLeft=0 on mount so the first milestone aligns with the content-column left edge. Previously the page jumped to the last-shipping milestone, which felt random once items past the viewport landed. 6. Events admin list shows the actual date (fmtDateTime) instead of "in 3 days" — easier to scan when planning across months. 7. duration_label is auto-computed from starts_at + ends_at on save (minutes < 90, hours < 4, "Half day", "Full day", "N days"). The manual field is gone from the admin form; the column on the member-facing event pages keeps reading the stored value as before. 8. Pulse hero still skips office hours per the existing logic — no change. Confirmed via the test note's clarification. 9. Pulse "also coming up" strip relabeled to Previous + Upcoming. Previous = most recent past non-office-hours event. Upcoming = next non-office-hours event after the hero. Each card now carries a small terracotta eyebrow with the label. Typecheck clean, build clean, 147/147 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
406 lines
13 KiB
Text
406 lines
13 KiB
Text
---
|
|
import AppLayout from '../../layouts/AppLayout.astro';
|
|
import Avatar from '../../components/Avatar.astro';
|
|
import {
|
|
getDispatchWithPoll, getAdjacentDispatches,
|
|
getPulseById, castOrChangeVote, recordActivity, countCabMembers,
|
|
} from '../../lib/db';
|
|
import {
|
|
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
|
dispatchKindPigment, roleLabel,
|
|
} from '../../lib/format';
|
|
import { renderMd } from '../../lib/markdown';
|
|
|
|
const user = Astro.locals.user;
|
|
const slugParam = Astro.params.slug ?? '';
|
|
const id = parseDispatchSlug(slugParam);
|
|
|
|
if (!id) return Astro.redirect('/dispatches');
|
|
|
|
// Vote POST — handled before main render so we can refresh state
|
|
if (Astro.request.method === 'POST') {
|
|
const data = await Astro.request.formData();
|
|
const action = String(data.get('action') ?? '');
|
|
if (action === 'vote') {
|
|
const pulseId = Number(data.get('pulse_id'));
|
|
const optionIndex = Number(data.get('option_index'));
|
|
const target = getPulseById(pulseId);
|
|
if (target && target.status === 'open' && Number.isInteger(optionIndex)
|
|
&& optionIndex >= 0 && optionIndex < target.options.length) {
|
|
const wasNew = castOrChangeVote(pulseId, user.id, optionIndex);
|
|
if (wasNew) recordActivity(user.id, 'voted', 'pulse', pulseId);
|
|
}
|
|
return Astro.redirect(Astro.url.pathname);
|
|
}
|
|
}
|
|
|
|
const d = getDispatchWithPoll(id, user.id);
|
|
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
|
|
|
|
// Canonical-redirect when the slug changes after a rename — id is the authority
|
|
const canonical = dispatchSlug(d);
|
|
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
|
|
|
const totalMembers = countCabMembers();
|
|
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);
|
|
}
|
|
|
|
function parseUtc(s: string): Date {
|
|
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
|
return new Date(s.replace(' ', 'T') + 'Z');
|
|
}
|
|
function fmt(iso: string): string {
|
|
return new Intl.DateTimeFormat('en-GB', {
|
|
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Copenhagen',
|
|
}).format(parseUtc(iso));
|
|
}
|
|
|
|
const bodyHtml = renderMd(d.body);
|
|
---
|
|
<AppLayout title={d.title} user={user}>
|
|
<article class="page">
|
|
|
|
<a href="/dispatches" class="section-link back-link">← All dispatches</a>
|
|
|
|
<header class="head">
|
|
<div class="head-meta">
|
|
<span class="kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
|
|
{dispatchKindLabel(d.kind)}
|
|
</span>
|
|
<time class="head-date label-sm" datetime={d.published_at ?? d.created_at}>
|
|
{fmt(d.published_at ?? d.created_at)}
|
|
</time>
|
|
</div>
|
|
|
|
<h1 class="title">{d.title}</h1>
|
|
|
|
<div class="byline">
|
|
<Avatar id={d.author_id} name={d.author_name} size={32} />
|
|
<div class="byline-text">
|
|
<span class="byline-name">{d.author_name}</span>
|
|
<span class="byline-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="body prose" set:html={bodyHtml} />
|
|
|
|
{d.poll && (
|
|
<aside class="inline-poll" aria-label="Poll attached to this dispatch">
|
|
<p class="inline-poll-question">{d.poll.question}</p>
|
|
<form method="POST" class="inline-poll-options" novalidate>
|
|
<input type="hidden" name="action" value="vote" />
|
|
<input type="hidden" name="pulse_id" value={d.poll.id} />
|
|
{d.poll.options.map((opt, i) => {
|
|
const hasVoted = d.poll!.my_vote !== null;
|
|
const chosen = d.poll!.my_vote === i;
|
|
const closed = d.poll!.status !== 'open';
|
|
const count = d.poll!.votes_by_option[i] ?? 0;
|
|
const pct = d.poll!.votes_total > 0 ? (count / d.poll!.votes_total) * 100 : 0;
|
|
const letter = String.fromCharCode(65 + i);
|
|
return (
|
|
<button
|
|
type="submit"
|
|
name="option_index"
|
|
value={i}
|
|
class:list={['inline-poll-option', { chosen, closed }]}
|
|
disabled={closed && !chosen}
|
|
aria-pressed={chosen}
|
|
>
|
|
<span class="inline-poll-letter">{letter}</span>
|
|
<span class="inline-poll-text">{opt}</span>
|
|
{hasVoted && (
|
|
<span class="inline-poll-pct">{pct.toFixed(0)}%</span>
|
|
)}
|
|
{hasVoted && (
|
|
<span class="inline-poll-bar" aria-hidden="true">
|
|
<span class="inline-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</form>
|
|
<p class="inline-poll-count">
|
|
{d.poll.votes_total} of {totalMembers} have weighed in
|
|
{d.poll.status === 'open'
|
|
? ` · closes ${closeDayLabel(d.poll.closes_at)}`
|
|
: ' · closed'}
|
|
</p>
|
|
</aside>
|
|
)}
|
|
|
|
<hr class="divider" />
|
|
|
|
<nav class="adjacent" aria-label="Adjacent dispatches">
|
|
{prev ? (
|
|
<a class="adj-card adj-prev" href={`/dispatches/${dispatchSlug(prev)}`}>
|
|
<span class="adj-direction label-sm">← Previous</span>
|
|
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(prev.kind)}`}>
|
|
{dispatchKindLabel(prev.kind)}
|
|
</span>
|
|
<span class="adj-title">{prev.title}</span>
|
|
</a>
|
|
) : (
|
|
<span class="adj-empty"></span>
|
|
)}
|
|
{next ? (
|
|
<a class="adj-card adj-next" href={`/dispatches/${dispatchSlug(next)}`}>
|
|
<span class="adj-direction label-sm">Next →</span>
|
|
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(next.kind)}`}>
|
|
{dispatchKindLabel(next.kind)}
|
|
</span>
|
|
<span class="adj-title">{next.title}</span>
|
|
</a>
|
|
) : (
|
|
<span class="adj-empty"></span>
|
|
)}
|
|
</nav>
|
|
|
|
</article>
|
|
</AppLayout>
|
|
|
|
<style>
|
|
.page {
|
|
padding: var(--space-12) var(--space-20) var(--space-16);
|
|
max-width: 1080px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-6);
|
|
}
|
|
|
|
.back-link { align-self: flex-start; }
|
|
|
|
.head { display: flex; flex-direction: column; gap: var(--space-4); }
|
|
|
|
.head-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
}
|
|
.kind-pill {
|
|
background: color-mix(in oklab, var(--pill) 14%, transparent);
|
|
color: var(--pill);
|
|
padding: 3px 12px;
|
|
border-radius: 999px;
|
|
font-family: var(--font-sans);
|
|
font-size: var(--text-label-sm);
|
|
letter-spacing: var(--tracking-wide);
|
|
font-weight: 600;
|
|
}
|
|
.head-date {
|
|
color: var(--on-surface-muted);
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.title {
|
|
font-family: var(--font-serif);
|
|
font-weight: 400;
|
|
font-size: 2rem;
|
|
line-height: 1.2;
|
|
letter-spacing: var(--tracking-tight);
|
|
color: var(--on-surface);
|
|
margin: 0;
|
|
}
|
|
|
|
.byline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
}
|
|
.byline-text { display: flex; flex-direction: column; gap: 2px; }
|
|
.byline-name { font-weight: 600; color: var(--on-surface); }
|
|
.byline-role {
|
|
color: var(--on-surface-muted);
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.body {
|
|
font-size: var(--text-body-lg);
|
|
line-height: 1.7;
|
|
color: var(--on-surface);
|
|
}
|
|
.body :global(p) { margin: 0 0 var(--space-4); }
|
|
.body :global(h2) {
|
|
font-family: var(--font-serif);
|
|
font-weight: 400;
|
|
font-size: 1.5rem;
|
|
margin: var(--space-6) 0 var(--space-3);
|
|
}
|
|
.body :global(h3) {
|
|
font-family: var(--font-serif);
|
|
font-weight: 400;
|
|
font-size: 1.25rem;
|
|
margin: var(--space-5) 0 var(--space-2);
|
|
}
|
|
.body :global(blockquote) {
|
|
border-left: 2px solid color-mix(in oklab, var(--pigment-terracotta) 40%, transparent);
|
|
padding-left: var(--space-4);
|
|
color: var(--on-surface-variant);
|
|
margin: var(--space-5) 0;
|
|
}
|
|
.body :global(code) {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.9em;
|
|
background: var(--surface-container);
|
|
padding: 0.15em 0.4em;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.body :global(ul), .body :global(ol) {
|
|
padding-left: var(--space-5);
|
|
margin: 0 0 var(--space-4);
|
|
}
|
|
|
|
.divider {
|
|
border: none;
|
|
height: 0.5px;
|
|
background: var(--surface-card-border);
|
|
margin: var(--space-6) 0 0;
|
|
}
|
|
|
|
/* ── 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);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--space-6);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-4);
|
|
}
|
|
.inline-poll-question {
|
|
font-family: var(--font-serif);
|
|
font-weight: 400;
|
|
font-size: 1.25rem;
|
|
line-height: 1.3;
|
|
color: var(--on-surface);
|
|
margin: 0;
|
|
}
|
|
.inline-poll-options {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
.inline-poll-option {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
padding: var(--space-3) var(--space-4);
|
|
background: var(--background);
|
|
border: 0.5px solid var(--surface-card-border);
|
|
border-radius: var(--radius-md);
|
|
font-family: var(--font-sans);
|
|
font-size: var(--text-body-md);
|
|
color: var(--on-surface);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
transition: border-color var(--duration-fast) var(--ease-standard),
|
|
background var(--duration-fast) var(--ease-standard);
|
|
overflow: hidden;
|
|
}
|
|
.inline-poll-option:hover:not(.closed) { border-color: var(--outline); }
|
|
.inline-poll-option.chosen {
|
|
border-color: var(--pigment-terracotta);
|
|
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
|
|
}
|
|
.inline-poll-option.closed:not(.chosen) {
|
|
cursor: default;
|
|
color: var(--on-surface-variant);
|
|
}
|
|
.inline-poll-pct {
|
|
margin-left: auto;
|
|
font-family: var(--font-sans);
|
|
font-size: var(--text-label-sm);
|
|
font-weight: 600;
|
|
letter-spacing: var(--tracking-wider);
|
|
color: var(--on-surface-variant);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.inline-poll-option.chosen .inline-poll-pct { color: var(--pigment-terracotta); }
|
|
.inline-poll-option:disabled { opacity: 0.85; }
|
|
.inline-poll-letter {
|
|
font-weight: 600;
|
|
color: var(--on-surface-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
.inline-poll-option.chosen .inline-poll-letter { color: var(--pigment-terracotta); }
|
|
.inline-poll-text { flex: 1; }
|
|
.inline-poll-bar {
|
|
position: absolute;
|
|
left: 0; right: 0; bottom: 0;
|
|
height: 2px;
|
|
background: var(--surface-container);
|
|
}
|
|
.inline-poll-bar-fill {
|
|
display: block;
|
|
height: 100%;
|
|
background: var(--pigment-terracotta);
|
|
opacity: 0.55;
|
|
transition: width 600ms var(--ease-standard);
|
|
}
|
|
.inline-poll-count {
|
|
color: var(--on-surface-muted);
|
|
font-size: var(--text-label-sm);
|
|
letter-spacing: var(--tracking-wide);
|
|
margin: 0;
|
|
}
|
|
|
|
.adjacent {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: var(--space-5);
|
|
}
|
|
.adj-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
padding: var(--space-5);
|
|
background: var(--surface-card);
|
|
border: 0.5px solid var(--surface-card-border);
|
|
border-radius: var(--radius-lg);
|
|
text-decoration: none;
|
|
border-bottom: 0.5px solid var(--surface-card-border);
|
|
color: inherit;
|
|
transition: transform 300ms var(--ease-standard);
|
|
}
|
|
.adj-card:hover { transform: translateY(-2px); border-bottom-color: var(--surface-card-border); }
|
|
.adj-next { text-align: right; align-items: flex-end; }
|
|
|
|
.adj-direction {
|
|
color: var(--on-surface-muted);
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
}
|
|
.adj-kind-pill {
|
|
align-self: flex-start;
|
|
background: color-mix(in oklab, var(--pill) 14%, transparent);
|
|
color: var(--pill);
|
|
padding: 2px 9px;
|
|
border-radius: 999px;
|
|
font-family: var(--font-sans);
|
|
font-size: var(--text-label-sm);
|
|
letter-spacing: var(--tracking-wide);
|
|
font-weight: 600;
|
|
}
|
|
.adj-next .adj-kind-pill { align-self: flex-end; }
|
|
.adj-title {
|
|
font-family: var(--font-serif);
|
|
color: var(--on-surface);
|
|
}
|
|
.adj-empty {} /* placeholder for missing prev/next slot */
|
|
|
|
@media (max-width: 640px) {
|
|
.adjacent { grid-template-columns: 1fr; }
|
|
.adj-next { text-align: left; align-items: flex-start; }
|
|
.adj-next .adj-kind-pill { align-self: flex-start; }
|
|
}
|
|
</style>
|