worldwide todo list: public todo list of 68 real, sourced problems (planet to street) at /projects/worldwide-todo. worldtodo schema (categories, tasks, change_requests), idempotent seed on deploy, public add/edit/delete requests with Turnstile + salted daily-rotating IP-hash rate limit, owner-only review queue (approve, edit & approve, reject) with live updates over /ws/worldtodo(-owner), JSON API, detail pages with OSM map and history; listed in projects, header, /start and sitemap
Some checks failed
Deploy production / deploy (push) Failing after 9s

This commit is contained in:
2026-10-26 01:25:59 +00:00
parent 0ee981a96b
commit 5e812271e1
58 changed files with 47689 additions and 9 deletions

View File

@@ -0,0 +1,12 @@
import { isOwner } from '#lib/server/owner.js';
import { listCategories, pendingTotal } from '#lib/server/worldtodo/store.js';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
const owner = isOwner(event);
const [categories, pending] = await Promise.all([
listCategories(),
owner ? pendingTotal() : Promise.resolve(0)
]);
return { owner, categories, pendingCount: pending };
};

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { invalidate } from '$app/navigation';
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import ListTodo from 'lucide-svelte/icons/list-todo';
import Plus from 'lucide-svelte/icons/plus';
import Inbox from 'lucide-svelte/icons/inbox';
import { subscribe } from '#lib/worldtodo/live';
let { data, children } = $props();
const base = '/projects/worldwide-todo';
// Owner: live queue badge over the owner channel, polling every 60 s when it's down.
let pending = $state<number | null>(null);
const pendingCount = $derived(pending ?? data.pendingCount);
$effect(() => {
if (!data.owner) return;
let up = false;
const stop = subscribe(
'worldtodo-owner',
(msg) => {
const m = msg as { type?: string; pending?: number };
if (m.type === 'queue.changed' && typeof m.pending === 'number') {
pending = m.pending;
invalidate('worldtodo:queue');
}
},
(state) => (up = state)
);
const poll = setInterval(() => {
if (!up && document.visibilityState === 'visible') {
pending = null;
invalidate('worldtodo:queue');
}
}, 60_000);
return () => {
stop();
clearInterval(poll);
};
});
const tabs = $derived([
{ key: 'list', name: 'tasks', href: base, icon: ListTodo, locked: false },
{
key: 'add',
name: 'suggest a task',
href: `${base}/request?kind=add`,
icon: Plus,
locked: false
},
...(data.owner
? [{ key: 'queue', name: 'queue', href: `${base}/queue`, icon: Inbox, locked: true }]
: [])
]);
const activeKey = $derived.by(() => {
const p = page.url.pathname;
if (p.startsWith(`${base}/queue`)) return 'queue';
if (p.startsWith(`${base}/request`) && !p.startsWith(`${base}/requests`))
return page.url.searchParams.get('kind') === 'add' || !page.url.searchParams.get('kind')
? 'add'
: '';
if (p === base) return 'list';
return '';
});
</script>
<div class="wt-page">
<header class="wt-head">
<span class="wt-icon"><ProjectIcon name="worldwide todo list" class="size-full" /></span>
<div>
<p class="eyebrow"><a href={resolve('/projects')}>projects</a></p>
<h1>the worldwide todo list</h1>
<p class="lede">
Real problems, phrased as todos. Anyone can suggest changes; Stan reviews them.
</p>
</div>
</header>
<nav class="wt-tabs" aria-label="Worldwide todo list">
{#each tabs as tab (tab.key)}
{@const active = activeKey === tab.key}
<a
href={tab.href}
class={{ active }}
aria-current={active ? 'page' : undefined}
data-sveltekit-preload-data="hover"
><span class="tab-icon tab-{tab.key}"><tab.icon size={14} aria-hidden="true" /></span
>{tab.name}{#if tab.locked}<PrivateLock
size={11}
/>{/if}{#if tab.key === 'queue' && pendingCount}<span
class="badge"
aria-label="{pendingCount} pending">{pendingCount}</span
>{/if}</a
>
{/each}
</nav>
<div class="wt-main">
{@render children()}
</div>
</div>
<style>
.wt-page {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.wt-head {
display: flex;
align-items: center;
gap: 0.9rem;
}
.wt-icon {
display: inline-flex;
width: 3rem;
height: 3rem;
flex-shrink: 0;
}
.eyebrow {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--site-muted);
}
.eyebrow a {
color: inherit;
text-decoration: none;
}
.eyebrow a:hover {
color: var(--site-text);
}
.wt-head h1 {
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.lede {
color: var(--site-muted);
font-size: 0.9rem;
}
.wt-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
}
.wt-tabs a {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.85rem;
color: var(--site-muted);
white-space: nowrap;
text-decoration: none;
transition:
color 0.15s,
border-color 0.15s,
background 0.15s;
}
.wt-tabs a:hover,
.wt-tabs a:focus-visible {
color: var(--site-text);
border-color: var(--site-muted);
}
.wt-tabs a.active {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
color: #fff;
}
.tab-list {
background: linear-gradient(135deg, #38bdf8, #16a34a);
}
.tab-add {
background: linear-gradient(135deg, #fbbf24, #ea580c);
}
.tab-queue {
background: linear-gradient(135deg, #a78bfa, #db2777);
}
.badge {
min-width: 1.2rem;
padding: 0 0.3rem;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 0.7rem;
font-weight: 600;
text-align: center;
font-variant-numeric: tabular-nums;
}
.wt-main {
display: flex;
flex-direction: column;
gap: 1.25rem;
min-width: 0;
}
</style>

View File

@@ -0,0 +1,35 @@
import { counts, listTasks, type Sort } from '#lib/server/worldtodo/store.js';
import { WORLDTODO_SCALES } from '#lib/worldtodo/shared.js';
import type { PageServerLoad } from './$types';
const STATUSES = ['todo', 'open', 'in_progress', 'done', 'all'];
const SORTS: Sort[] = ['updated', 'scale', 'title'];
// Filters live in the query string (shareable, SSR, works without JS). A GET form submits
// toggle buttons that come before hidden copies of the current values, so the first value of
// each name wins.
export const load: PageServerLoad = async ({ url, parent }) => {
const first = (key: string) => url.searchParams.getAll(key)[0]?.trim() ?? '';
const { categories } = await parent();
const scale = (WORLDTODO_SCALES as readonly string[]).includes(first('scale'))
? first('scale')
: '';
const category = categories.some((c) => c.slug === first('category')) ? first('category') : '';
const status = STATUSES.includes(first('status')) ? first('status') : 'todo';
const sort = (SORTS as string[]).includes(first('sort')) ? (first('sort') as Sort) : 'updated';
const q = first('q').slice(0, 100);
const filter = { scale, category, status, q };
const [list, totals] = await Promise.all([listTasks(filter, sort, 500), counts(filter)]);
return {
filter,
sort,
tasks: list.tasks,
counts: totals,
seo: {
title: 'The worldwide todo list',
description:
'A public todo list of real, sourced problems, from planet-scale to a single street. Anyone can suggest additions, edits and removals.',
index: !scale && !category && status === 'todo' && !q && sort === 'updated'
}
};
};

View File

@@ -0,0 +1,470 @@
<script lang="ts">
import { untrack } from 'svelte';
import TaskCard from '#lib/worldtodo/TaskCard.svelte';
import IconTile from '#lib/worldtodo/IconTile.svelte';
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
import { subscribe } from '#lib/worldtodo/live';
import {
SCALE_INFO,
WORLDTODO_SCALES,
matchesFilters,
type LiveEvent,
type PublicTask
} from '#lib/worldtodo/shared';
import Plus from 'lucide-svelte/icons/plus';
import Search from 'lucide-svelte/icons/search';
let { data } = $props();
const catBySlug = $derived(new Map(data.categories.map((c) => [c.slug, c])));
const statusTabs = [
{ key: 'todo', label: 'To do' },
{ key: 'open', label: 'Open' },
{ key: 'in_progress', label: 'In progress' },
{ key: 'done', label: 'Done (wins)' },
{ key: 'all', label: 'All' }
];
// Realtime: approved changes patch the SSR list in place until the next navigation.
let overrides = $state<Record<string, PublicTask | null>>({});
let added = $state<PublicTask[]>([]);
let flashing = $state<string | null>(null);
let announce = $state('');
let now = $state(Date.now());
$effect(() => {
void data.tasks;
untrack(() => {
overrides = {};
added = [];
});
});
const tasks = $derived.by(() => {
const base = data.tasks
.map((t) => (t.slug in overrides ? overrides[t.slug] : t))
.filter((t): t is PublicTask => !!t);
const extra = added.filter((t) => !base.some((b) => b.slug === t.slug));
return [...extra, ...base].filter((t) => matchesFilters(t, data.filter));
});
$effect(() => {
const tick = setInterval(() => (now = Date.now()), 60_000);
let flashTimer: ReturnType<typeof setTimeout> | undefined;
const stop = subscribe('worldtodo', (msg) => {
const e = msg as LiveEvent;
if (!e?.slug || !e.type?.startsWith('task.')) return;
const inList = data.tasks.some((t) => t.slug === e.slug);
if (e.type === 'task.deleted' || !e.task) {
overrides = { ...overrides, [e.slug]: null };
added = added.filter((t) => t.slug !== e.slug);
announce = 'A task was removed.';
return;
}
if (inList) overrides = { ...overrides, [e.slug]: e.task };
else added = [e.task, ...added.filter((t) => t.slug !== e.slug)];
announce = `${e.type === 'task.added' ? 'Task added' : 'Task updated'}: ${e.task.title}`;
flashing = e.slug;
clearTimeout(flashTimer);
flashTimer = setTimeout(() => (flashing = null), 2500);
});
return () => {
stop();
clearInterval(tick);
clearTimeout(flashTimer);
};
});
// Search: debounced 300 ms, submits the GET form (focus and scroll are kept).
let form: HTMLFormElement;
let searchEl: HTMLInputElement;
let q = $state(untrack(() => data.filter.q));
let searchTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
const next = data.filter.q;
untrack(() => {
if (document.activeElement !== searchEl) q = next;
});
});
const onSearch = () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => form.requestSubmit(), 300);
};
const f = $derived(data.filter);
const total = $derived(data.counts.status.open + data.counts.status.in_progress);
</script>
<section class="hero" aria-label="Totals">
<p>
<strong>{data.counts.status.open}</strong> open tasks ·
<strong>{data.counts.status.in_progress}</strong> in progress ·
<strong>{data.counts.status.done}</strong> done
</p>
<p class="sub">
{total} real, sourced problems from planet-scale down to a single street. Spot something wrong, outdated
or missing? Suggest a change; nothing changes until it's reviewed.
</p>
</section>
<form
bind:this={form}
method="GET"
class="filters"
data-sveltekit-keepfocus
data-sveltekit-noscroll
aria-label="Filter tasks"
>
<div class="scales" role="group" aria-label="Scale">
{#each WORLDTODO_SCALES as s (s)}
{@const on = f.scale === s}
<button
type="submit"
name="scale"
value={on ? '' : s}
class={['scale', { on }]}
aria-pressed={on}
>
<IconTile icon={SCALE_ICONS[s].icon} background={SCALE_ICONS[s].gradient} size="lg" />
<span class="scale-label">{SCALE_INFO[s].label}</span>
<span class="count">{data.counts.scale[s] ?? 0}</span>
</button>
{/each}
</div>
<div class="cats" role="group" aria-label="Category">
{#each data.categories as c (c.slug)}
{@const on = f.category === c.slug}
<button
type="submit"
name="category"
value={on ? '' : c.slug}
class={['chip', { on }]}
aria-pressed={on}
><IconTile icon={categoryIcon(c.icon)} background={c.colour} size="sm" />{c.label}</button
>
{/each}
</div>
<div class="bar">
<div class="seg" role="group" aria-label="Status">
{#each statusTabs as s (s.key)}
<button
type="submit"
name="status"
value={s.key}
class={{ on: f.status === s.key }}
aria-pressed={f.status === s.key}>{s.label}</button
>
{/each}
</div>
<label class="search">
<span class="sr-only">Search tasks</span>
<Search size={15} aria-hidden="true" />
<input
bind:this={searchEl}
type="search"
name="q"
bind:value={q}
oninput={onSearch}
placeholder="Search tasks, places…"
maxlength="100"
autocomplete="off"
/>
</label>
<label class="sort">
<span>Sort</span>
<select name="sort" value={data.sort} onchange={() => form.requestSubmit()}>
<option value="updated">Recently updated</option>
<option value="scale">Scale</option>
<option value="title">Title</option>
</select>
</label>
<noscript><button type="submit" class="go">Apply</button></noscript>
</div>
<!-- Current values, after the buttons so a pressed button's value wins. -->
{#if f.scale}<input type="hidden" name="scale" value={f.scale} />{/if}
{#if f.category}<input type="hidden" name="category" value={f.category} />{/if}
{#if f.status !== 'todo'}<input type="hidden" name="status" value={f.status} />{/if}
</form>
<p class="results" aria-live="polite">
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q}&nbsp;matching “{f.q}”{/if}
{#if f.scale || f.category || f.q || f.status !== 'todo'}
· <a href="/projects/worldwide-todo" data-sveltekit-noscroll>clear filters</a>
{/if}
</p>
<p class="sr-only" aria-live="polite">{announce}</p>
<ul class="grid">
<li>
<a class="suggest" href="/projects/worldwide-todo/request?kind=add">
<span class="plus"><Plus size={22} aria-hidden="true" /></span>
<h3>Suggest a task</h3>
<span>Phrase it as something to do and link a source. It shows up here once approved.</span>
</a>
</li>
{#each tasks as task (task.slug)}
<li>
<TaskCard
{task}
category={catBySlug.get(task.category)}
flash={flashing === task.slug}
{now}
/>
</li>
{/each}
</ul>
{#if !tasks.length}
<p class="empty">Nothing matches these filters.</p>
{/if}
<style>
.hero p {
font-size: 1rem;
}
.hero strong {
font-variant-numeric: tabular-nums;
}
.hero .sub {
margin-top: 0.2rem;
color: var(--site-muted);
font-size: 0.85rem;
max-width: 46rem;
}
.filters {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.scales {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
}
.scale {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 0.75rem 0.4rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.85rem;
background: var(--site-surface);
color: var(--site-text);
cursor: pointer;
transition:
border-color 0.15s,
transform 0.15s,
box-shadow 0.15s;
}
.scale:hover,
.scale:focus-visible {
border-color: var(--site-muted);
transform: translateY(-1px);
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
}
.scale.on {
border-color: var(--site-text);
box-shadow: inset 0 0 0 1px var(--site-text);
}
.scale-label {
font-size: 0.85rem;
font-weight: 550;
}
.count {
font-size: 0.72rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.cats {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-muted);
background: transparent;
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.chip:hover,
.chip:focus-visible {
color: var(--site-text);
border-color: var(--site-muted);
}
.chip.on {
color: var(--site-text);
border-color: var(--site-text);
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.seg {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.15rem;
overflow-x: auto;
max-width: 100%;
scrollbar-width: none;
}
.seg button {
padding: 0.25rem 0.7rem;
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-muted);
background: transparent;
white-space: nowrap;
cursor: pointer;
}
.seg button:hover,
.seg button:focus-visible {
color: var(--site-text);
}
.seg button.on {
background: var(--site-text);
color: var(--site-bg);
}
.search {
flex: 1 1 14rem;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.7rem;
border: 1px solid var(--site-border);
border-radius: 999px;
color: var(--site-muted);
}
.search:focus-within {
border-color: var(--site-text);
}
.search input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: none;
color: var(--site-text);
font-size: 0.85rem;
}
.sort {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.sort select,
.go {
padding: 0.3rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 999px;
background: var(--site-bg);
color: var(--site-text);
font-size: 0.8rem;
}
.results {
font-size: 0.8rem;
color: var(--site-muted);
}
.results a {
color: var(--site-text);
}
.grid {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
}
.suggest {
display: flex;
flex-direction: column;
gap: 0.4rem;
height: 100%;
padding: 0.85rem 0.9rem;
border: 1px dashed var(--site-muted);
border-radius: 0.85rem;
color: var(--site-text);
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
box-shadow 0.15s;
}
.suggest:hover,
.suggest:focus-visible {
border-color: var(--site-text);
border-style: solid;
transform: translateY(-1px);
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
}
.suggest h3 {
font-size: 0.98rem;
font-weight: 600;
}
.suggest span:last-child {
font-size: 0.83rem;
color: var(--site-muted);
}
.plus {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.85rem;
height: 1.85rem;
border-radius: 0.5rem;
color: #fff;
background: linear-gradient(135deg, #fbbf24, #ea580c);
}
.empty {
color: var(--site-muted);
padding: 1rem 0;
}
@media (max-width: 640px) {
.scales {
grid-template-columns: repeat(5, minmax(4.2rem, 1fr));
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.2rem;
}
.scale {
padding: 0.55rem 0.2rem 0.45rem;
}
.scale :global(.lg) {
width: 2.1rem;
height: 2.1rem;
}
.cats {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.2rem;
}
.chip {
white-space: nowrap;
}
}
@media (prefers-reduced-motion: reduce) {
.scale,
.suggest {
transition: none;
}
.scale:hover,
.scale:focus-visible,
.suggest:hover,
.suggest:focus-visible {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,19 @@
import { error } from '@sveltejs/kit';
import { getTask, taskActivity } from '#lib/server/worldtodo/store.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
if (params.slug.length > 120) error(404, 'Not Found');
const found = await getTask(params.slug);
if (!found) error(404, 'Not Found');
const activity = await taskActivity(found.task.id);
return {
task: found.task,
history: activity.history,
pending: activity.pending,
seo: {
title: `${found.task.title} · worldwide todo list`,
description: found.task.description.slice(0, 200)
}
};
};

View File

@@ -0,0 +1,502 @@
<script lang="ts">
import IconTile from '#lib/worldtodo/IconTile.svelte';
import StatusPill from '#lib/worldtodo/StatusPill.svelte';
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
import { subscribe } from '#lib/worldtodo/live';
import { invalidateAll, goto } from '$app/navigation';
import { SCALE_INFO, relativeTime, type LiveEvent } from '#lib/worldtodo/shared';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import History from 'lucide-svelte/icons/history';
import Clock from 'lucide-svelte/icons/clock';
let { data } = $props();
const t = $derived(data.task);
const cat = $derived(data.categories.find((c) => c.slug === t.category));
const scale = $derived(SCALE_ICONS[t.scale]);
let now = $state(Date.now());
let announce = $state('');
let flash = $state(false);
let removed = $state(false);
$effect(() => {
const slug = data.task.slug;
let timer: ReturnType<typeof setTimeout> | undefined;
const stop = subscribe('worldtodo', (msg) => {
const e = msg as LiveEvent;
if (e?.slug !== slug) return;
if (e.type === 'task.deleted') {
removed = true;
announce = 'This task was removed.';
return;
}
announce = `Task updated: ${e.task?.title ?? ''}`;
flash = true;
clearTimeout(timer);
timer = setTimeout(() => (flash = false), 2500);
invalidateAll();
});
const tick = setInterval(() => (now = Date.now()), 60_000);
return () => {
stop();
clearInterval(tick);
clearTimeout(timer);
};
});
// Static OSM map: 3×3 tiles around the point with a marker; no scripts, no tracking.
const ZOOM = { planet: 2, region: 4, country: 5, city: 11, street: 15 } as const;
const map = $derived.by(() => {
if (t.lat === null || t.lon === null) return null;
const z = ZOOM[t.scale];
const n = 2 ** z;
const x = ((t.lon + 180) / 360) * n;
const latR = (t.lat * Math.PI) / 180;
const y = ((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2) * n;
const cx = Math.floor(x);
const cy = Math.floor(y);
const tiles: { src: string; left: number; top: number }[] = [];
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++) {
const ty = cy + dy;
if (ty < 0 || ty >= n) continue;
const tx = (((cx + dx) % n) + n) % n;
tiles.push({
src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`,
left: (dx + 1) * 256,
top: (dy + 1) * 256
});
}
// Marker position within the 768×768 grid.
const mx = (x - cx + 1) * 256;
const my = (y - cy + 1) * 256;
return {
tiles,
mx,
my,
link: `https://www.openstreetmap.org/?mlat=${t.lat}&mlon=${t.lon}#map=${z}/${t.lat}/${t.lon}`
};
});
const host = (url: string) => {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
};
const fmtDate = (iso: string) =>
new Date(iso.length === 10 ? `${iso}T12:00:00Z` : iso).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const kindLabel = { add: 'Added', edit: 'Edited', delete: 'Removed' } as const;
const pendingLabel = { add: 'addition', edit: 'edit', delete: 'removal request' } as const;
</script>
<p class="sr-only" aria-live="polite">{announce}</p>
<a class="back" href="/projects/worldwide-todo"
><ArrowLeft size={14} aria-hidden="true" />all tasks</a
>
{#if removed}
<div class="notice" role="status">
This task was just removed. <button
type="button"
onclick={() => goto('/projects/worldwide-todo')}>Back to the list</button
>
</div>
{/if}
<article class={['task', { flash }]}>
<div class="tags">
<span class="tag"
><IconTile icon={scale.icon} background={scale.gradient} />{SCALE_INFO[t.scale].label}</span
>
<span class="tag"
><IconTile
icon={categoryIcon(cat?.icon)}
background={cat?.colour ?? '#737373'}
/>{cat?.label ?? t.category}</span
>
<StatusPill status={t.status} />
{#if t.confidence === 'medium'}<span class="conf" title={t.verificationNote ?? ''}
>medium confidence</span
>{/if}
</div>
<h2>{t.title}</h2>
<p class="where">
{t.locationName}{#if t.countryCode}&nbsp;· {t.countryCode}{/if}
</p>
{#if data.pending.length}
<div class="pending" role="note">
<Clock size={15} aria-hidden="true" />
<div>
<strong
>{data.pending.length} pending {data.pending.length === 1
? pendingLabel[data.pending[0].kind]
: 'requests'}</strong
>
<ul>
{#each data.pending as p (p.id)}
<li>{p.summary} · {relativeTime(p.at, now) || 'just now'}</li>
{/each}
</ul>
<span class="hint">Already covered? No need to file it again.</span>
</div>
</div>
{/if}
<div class="body">
<div class="main">
<p class="desc">{t.description}</p>
<h3>Sources</h3>
<ul class="sources">
{#each t.sourceUrls as url (url)}
<li>
<a href={url} target="_blank" rel="noopener nofollow noreferrer"
><span>{host(url)}</span><ArrowUpRight size={14} aria-hidden="true" /></a
>
</li>
{/each}
</ul>
<p class="asof">
Facts as of {fmtDate(t.asOf)}{#if t.sourceDate}&nbsp;· source dated {t.sourceDate}{/if} · updated
{relativeTime(t.updatedAt, now) || 'just now'}
</p>
{#if t.verificationNote}<p class="note">Note: {t.verificationNote}</p>{/if}
<div class="actions">
<a class="btn" href="/projects/worldwide-todo/request?kind=edit&task={t.slug}"
><Pencil size={15} aria-hidden="true" />Suggest edit</a
>
<a class="btn" href="/projects/worldwide-todo/request?kind=delete&task={t.slug}"
><Trash2 size={15} aria-hidden="true" />Request removal</a
>
</div>
</div>
{#if map}
<a
class="map"
href={map.link}
target="_blank"
rel="noopener nofollow noreferrer"
aria-label="Open {t.locationName} on OpenStreetMap"
>
<div class="tiles" style:--mx="{map.mx}px" style:--my="{map.my}px">
{#each map.tiles as tile (tile.src)}
<img
src={tile.src}
alt=""
width="256"
height="256"
loading="lazy"
referrerpolicy="no-referrer"
style:left="{tile.left}px"
style:top="{tile.top}px"
/>
{/each}
<span class="marker" aria-hidden="true"></span>
</div>
<span class="attrib">© OpenStreetMap contributors</span>
</a>
{/if}
</div>
</article>
<section class="history" aria-labelledby="history-h">
<h3 id="history-h"><History size={15} aria-hidden="true" />History</h3>
<ol>
{#each data.history as h (h.id)}
<li>
<span class="dot" aria-hidden="true"></span>
<span
><strong>{kindLabel[h.kind]}</strong> · {h.summary}{#if h.ownerNote}<span class="onote">
— “{h.ownerNote}”</span
>{/if}</span
>
<time datetime={h.at}>{fmtDate(h.at)}</time>
</li>
{/each}
<li>
<span class="dot" aria-hidden="true"></span>
<span><strong>Listed</strong> · on the worldwide todo list</span>
<time datetime={t.createdAt}>{fmtDate(t.createdAt)}</time>
</li>
</ol>
</section>
<style>
.back {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--site-muted);
text-decoration: none;
width: fit-content;
}
.back:hover {
color: var(--site-text);
}
.notice {
padding: 0.6rem 0.8rem;
border: 1px solid var(--site-text);
border-radius: 0.75rem;
font-size: 0.85rem;
}
.notice button {
text-decoration: underline;
}
.task {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1.1rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
background: var(--site-surface);
transition: background-color 0.6s;
}
.task.flash {
background: color-mix(in srgb, var(--site-surface) 85%, #facc15);
}
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
font-weight: 550;
}
.conf {
font-size: 0.72rem;
padding: 0.1rem 0.5rem;
border: 1px dashed var(--site-muted);
border-radius: 999px;
color: var(--site-muted);
}
h2 {
font-size: clamp(1.25rem, 3.5vw, 1.6rem);
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.2;
}
.where {
color: var(--site-muted);
font-size: 0.9rem;
}
.pending {
display: flex;
gap: 0.6rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--site-text);
border-radius: 0.75rem;
font-size: 0.83rem;
}
.pending ul {
margin-top: 0.2rem;
color: var(--site-muted);
}
.hint {
font-size: 0.75rem;
color: var(--site-muted);
}
.body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
gap: 1.25rem;
align-items: start;
}
.body:not(:has(.map)) {
grid-template-columns: minmax(0, 1fr);
}
.main {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.desc {
font-size: 0.98rem;
line-height: 1.55;
white-space: pre-line;
max-width: 44rem;
}
h3 {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--site-muted);
margin-top: 0.4rem;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.sources a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-text);
text-decoration: none;
max-width: 100%;
transition:
border-color 0.15s,
transform 0.15s;
}
.sources a span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sources a:hover,
.sources a:focus-visible {
border-color: var(--site-muted);
transform: translateY(-1px);
}
.asof,
.note {
font-size: 0.75rem;
color: var(--site-muted);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.3rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border: 1px solid var(--site-border);
border-radius: 0.65rem;
background: var(--site-bg);
color: var(--site-text);
font-size: 0.85rem;
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
box-shadow 0.15s;
}
.btn:hover,
.btn:focus-visible {
border-color: var(--site-muted);
transform: translateY(-1px);
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
}
.map {
position: relative;
display: block;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--site-border);
border-radius: 0.85rem;
filter: grayscale(0.6);
transition: filter 0.2s;
}
.map:hover,
.map:focus-visible {
filter: none;
}
.tiles {
position: absolute;
width: 768px;
height: 768px;
left: 50%;
top: 50%;
transform: translate(calc(-1 * var(--mx)), calc(-1 * var(--my)));
}
.tiles img {
position: absolute;
max-width: none;
}
.marker {
position: absolute;
left: var(--mx);
top: var(--my);
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 999px;
background: #dc2626;
border: 3px solid #fff;
box-shadow: 0 0 0 2px #000;
}
.attrib {
position: absolute;
right: 0;
bottom: 0;
padding: 0.1rem 0.35rem;
font-size: 0.6rem;
background: rgb(255 255 255 / 0.8);
color: #000;
}
.history ol {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
border-left: 1px solid var(--site-border);
padding-left: 0.9rem;
}
.history li {
position: relative;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.25rem 1rem;
font-size: 0.85rem;
}
.history .dot {
position: absolute;
left: calc(-0.9rem - 4.5px);
top: 0.4rem;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--site-text);
}
.history time,
.onote {
color: var(--site-muted);
font-size: 0.78rem;
}
@media (max-width: 720px) {
.body {
grid-template-columns: minmax(0, 1fr);
}
.map {
aspect-ratio: 16 / 10;
}
}
@media (prefers-reduced-motion: reduce) {
.btn,
.sources a,
.task {
transition: none;
}
.btn:hover,
.sources a:hover {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import { listCategories } from '#lib/server/worldtodo/store.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () =>
json(await listCategories(), { headers: { 'cache-control': 'public, max-age=300' } });

View File

@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { queue } from '#lib/server/worldtodo/store.js';
import type { RequestHandler } from './$types';
// Owner: full requests with the current task (for diffs), stale flags and similar titles.
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const raw = event.url.searchParams.get('status') ?? 'pending';
const status = (['pending', 'approved', 'rejected'] as const).find((s) => s === raw) ?? 'pending';
return json(await queue(status), { headers: { 'cache-control': 'private, no-store' } });
};

View File

@@ -0,0 +1,32 @@
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { approveRequest } from '#lib/server/worldtodo/decide.js';
import { DecisionError } from '#lib/server/worldtodo/store.js';
import { fieldsSchema } from '#lib/server/worldtodo/validate.js';
import type { TaskFields } from '#lib/worldtodo/shared.js';
import type { RequestHandler } from './$types';
// Owner: body { note?, payload? } where payload is the full tweaked field set.
export const POST: RequestHandler = async (event) => {
requireOwner(event);
const id = Number(event.params.id);
if (!Number.isSafeInteger(id) || id <= 0) error(404, 'Not Found');
const body = ((await event.request.json().catch(() => ({}))) ?? {}) as {
note?: string;
payload?: Record<string, unknown>;
};
let tweaked: TaskFields | null = null;
if (body.payload) {
const parsed = fieldsSchema.safeParse(body.payload);
if (!parsed.success)
return json({ error: parsed.error.issues.map((i) => i.message) }, { status: 400 });
tweaked = parsed.data as TaskFields;
}
try {
const applied = await approveRequest(id, body.note, tweaked);
return json({ ok: true, ...applied });
} catch (e) {
if (e instanceof DecisionError) return json({ error: e.message }, { status: e.status });
throw e;
}
};

View File

@@ -0,0 +1,20 @@
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { rejectRequest } from '#lib/server/worldtodo/decide.js';
import { DecisionError } from '#lib/server/worldtodo/store.js';
import type { RequestHandler } from './$types';
// Owner: body { note? }.
export const POST: RequestHandler = async (event) => {
requireOwner(event);
const id = Number(event.params.id);
if (!Number.isSafeInteger(id) || id <= 0) error(404, 'Not Found');
const body = ((await event.request.json().catch(() => ({}))) ?? {}) as { note?: string };
try {
await rejectRequest(id, body.note);
return json({ ok: true });
} catch (e) {
if (e instanceof DecisionError) return json({ error: e.message }, { status: e.status });
throw e;
}
};

View File

@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import { clientIp } from '#lib/server/worldtodo/limit.js';
import { submitRequest } from '#lib/server/worldtodo/requests.js';
import type { RequestHandler } from './$types';
// POST /projects/worldwide-todo/api/requests (public + Turnstile). JSON body:
// { kind, task?, title?, description?, scale?, category?, location_name?, country_code?, lat?,
// lon?, status?, source_urls?: string[], reason, display_name?, started, website?, turnstile }
// 201 { id, statusUrl } | 400 { error, fields? } | 429 (Retry-After) | 503.
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
if (!request.headers.get('content-type')?.includes('application/json'))
return json({ error: 'Send JSON' }, { status: 415 });
const text = await request.text();
if (text.length > 20_000) return json({ error: 'Too large' }, { status: 413 });
let body: Record<string, unknown>;
try {
body = JSON.parse(text);
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error();
} catch {
return json({ error: 'Invalid JSON' }, { status: 400 });
}
const result = await submitRequest({
input: body,
honeypot: body.website,
startedAt: body.started,
token: typeof body.turnstile === 'string' ? body.turnstile : '',
ip: clientIp(request, getClientAddress)
});
if (result.ok)
return json(
{ id: result.id, statusUrl: `/projects/worldwide-todo/requests/${result.id}` },
{ status: 201 }
);
return json(
{ error: result.message, ...(result.errors ? { fields: result.errors } : {}) },
{
status: result.status,
headers: result.retryAfter ? { 'retry-after': String(result.retryAfter) } : undefined
}
);
};

View File

@@ -0,0 +1,18 @@
import { error, json } from '@sveltejs/kit';
import { requestStatus } from '#lib/server/worldtodo/store.js';
import type { RequestHandler } from './$types';
// Minimal public status: kind, status, created_at, decided_at, owner_note.
export const GET: RequestHandler = async ({ params }) => {
if (!/^\d{1,12}$/.test(params.id)) error(404, 'Not Found');
const r = await requestStatus(Number(params.id));
if (!r) error(404, 'Not Found');
return json({
id: r.id,
kind: r.kind,
status: r.status,
created_at: r.createdAt,
decided_at: r.decidedAt,
owner_note: r.ownerNote
});
};

View File

@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { listTasks, type Sort } from '#lib/server/worldtodo/store.js';
import type { RequestHandler } from './$types';
// GET /projects/worldwide-todo/api/tasks?scale=&category=&status=&q=&sort=&cursor=
// 50 per page, keyset cursor (nextCursor), deleted tasks excluded. status defaults to 'todo'
// (open + in progress); 'all', 'open', 'in_progress' or 'done' otherwise.
export const GET: RequestHandler = async ({ url }) => {
const p = (k: string) => url.searchParams.get(k)?.trim() ?? '';
const scale = ['planet', 'region', 'country', 'city', 'street'].includes(p('scale'))
? p('scale')
: '';
const status = ['todo', 'open', 'in_progress', 'done', 'all'].includes(p('status'))
? p('status')
: 'todo';
const sort: Sort =
(['updated', 'scale', 'title'] as const).find((s) => s === p('sort')) ?? 'updated';
const category = /^[a-z][a-z0-9-]{1,30}$/.test(p('category')) ? p('category') : '';
const page = await listTasks(
{ scale, category, status, q: p('q').slice(0, 100) },
sort,
50,
p('cursor').slice(0, 500) || undefined
);
return json(page, { headers: { 'cache-control': 'public, max-age=30' } });
};

View File

@@ -0,0 +1,49 @@
import { error, json } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { editTaskDirectly } from '#lib/server/worldtodo/decide.js';
import {
DecisionError,
currentFields,
getTask,
taskActivity
} from '#lib/server/worldtodo/store.js';
import { fieldsSchema } from '#lib/server/worldtodo/validate.js';
import type { TaskFields } from '#lib/worldtodo/shared.js';
import type { RequestHandler } from './$types';
// GET: the task, its approved history and pending requests (summaries only).
export const GET: RequestHandler = async ({ params }) => {
const found = await getTask(params.slug);
if (!found) error(404, 'Not Found');
const activity = await taskActivity(found.task.id);
return json({
task: found.task,
history: activity.history,
pendingCount: activity.pending.length,
pending: activity.pending
});
};
// PATCH (owner): direct edit, body { fields: Partial<TaskFields>, note? }. Logged as an
// auto-approved edit request; published like any approval.
export const PATCH: RequestHandler = async (event) => {
requireOwner(event);
const body = (await event.request.json().catch(() => null)) as {
fields?: Record<string, unknown>;
note?: string;
} | null;
if (!body?.fields || typeof body.fields !== 'object') error(400, 'Body needs { fields }');
const found = await getTask(event.params.slug);
if (!found) error(404, 'Not Found');
const parsed = fieldsSchema.safeParse({ ...currentFields(found.task), ...body.fields });
if (!parsed.success)
return json({ error: parsed.error.issues.map((i) => i.message) }, { status: 400 });
try {
const applied = await editTaskDirectly(event.params.slug, parsed.data as TaskFields, body.note);
const fresh = await getTask(applied.slug);
return json({ ok: true, task: fresh?.task ?? null });
} catch (e) {
if (e instanceof DecisionError) return json({ error: e.message }, { status: e.status });
throw e;
}
};

View File

@@ -0,0 +1,79 @@
import { fail } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import { approveRequest, rejectRequest } from '#lib/server/worldtodo/decide.js';
import { DecisionError, queue } from '#lib/server/worldtodo/store.js';
import { fieldsSchema, formInput } from '#lib/server/worldtodo/validate.js';
import { client as sql } from '#lib/server/db/index.js';
import type { TaskFields } from '#lib/worldtodo/shared.js';
import type { Actions, PageServerLoad } from './$types';
// Owner moderation queue. Owner-only three times over: hooks.server.ts refuses non-GET
// requests from anyone else (ownerOnlyPrefixes), and every load and action calls requireOwner,
// which answers 404 like an unknown URL. SvelteKit's origin check covers CSRF.
const TABS = ['pending', 'approved', 'rejected'] as const;
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.depends('worldtodo:queue');
const raw = event.url.searchParams.get('status') ?? 'pending';
const status = (TABS as readonly string[]).includes(raw)
? (raw as (typeof TABS)[number])
: 'pending';
const [items, totals] = await Promise.all([
queue(status),
sql`select status::text as status, count(*)::int as n from worldtodo.change_requests group by status`
]);
const counts: Record<string, number> = { pending: 0, approved: 0, rejected: 0 };
for (const r of totals) counts[r.status] = r.n;
return {
status,
items,
counts,
seo: { title: 'Queue · worldwide todo list', description: 'Owner queue', index: false }
};
};
const idOf = (form: FormData) => {
const id = Number(form.get('id'));
return Number.isSafeInteger(id) && id > 0 ? id : null;
};
async function run(id: number | null, fn: (id: number) => Promise<unknown>, done: string) {
if (!id) return fail(400, { id, message: 'Missing request id' });
try {
await fn(id);
return { id, ok: done };
} catch (e) {
if (e instanceof DecisionError) return fail(e.status, { id, message: e.message });
throw e;
}
}
export const actions: Actions = {
approve: async (event) => {
requireOwner(event);
const form = await event.request.formData();
return run(idOf(form), (id) => approveRequest(id, form.get('note')), 'Approved');
},
tweak: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const parsed = fieldsSchema.safeParse(formInput(form));
if (!parsed.success)
return fail(400, {
id: idOf(form),
message: parsed.error.issues.map((i) => i.message).join(' · ')
});
return run(
idOf(form),
(id) => approveRequest(id, form.get('note'), parsed.data as TaskFields),
'Approved with tweaks'
);
},
reject: async (event) => {
requireOwner(event);
const form = await event.request.formData();
return run(idOf(form), (id) => rejectRequest(id, form.get('note')), 'Rejected');
}
};

View File

@@ -0,0 +1,576 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TaskFieldsEditor from '#lib/worldtodo/TaskFieldsEditor.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import {
EDITABLE_FIELDS,
FIELD_LABELS,
fieldsOf,
formatValue,
relativeTime,
type EditableField
} from '#lib/worldtodo/shared';
import Check from 'lucide-svelte/icons/check';
import X from 'lucide-svelte/icons/x';
import PencilLine from 'lucide-svelte/icons/pencil-line';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
import Copy from 'lucide-svelte/icons/copy';
let { data, form } = $props();
type Item = (typeof data.items)[number];
const tabs = [
{ key: 'pending', label: 'Pending' },
{ key: 'approved', label: 'Approved' },
{ key: 'rejected', label: 'Rejected' }
] as const;
const kindLabel = { add: 'Add', edit: 'Edit', delete: 'Delete' } as const;
let selected = $state(0);
let tweaking = $state<number | null>(null);
let busy = $state<number | null>(null);
let now = $state(Date.now());
const cards: HTMLElement[] = $state([]);
$effect(() => {
const t = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(t);
});
$effect(() => {
if (selected >= data.items.length) selected = Math.max(0, data.items.length - 1);
});
/** Field rows: old → new. For decided requests, what was applied. */
function diff(item: Item) {
const change = (
item.status === 'pending' ? item.payload : (item.applied ?? item.payload)
) as Record<string, unknown>;
const current = item.task ? fieldsOf(item.task) : null;
if (item.kind === 'delete')
return current
? (['title', 'location_name', 'status'] as EditableField[]).map((k) => ({
key: k,
old: formatValue(k, current[k]),
new: '—'
}))
: [];
return EDITABLE_FIELDS.filter((k) => k in change).map((k) => ({
key: k,
old: item.kind === 'add' || !current ? '—' : formatValue(k, current[k]),
new: formatValue(k, change[k])
}));
}
function tweakValues(item: Item) {
const base = item.task ? fieldsOf(item.task) : {};
return { ...base, ...item.payload } as Record<string, unknown>;
}
const sources = (item: Item) =>
((item.payload.source_urls as string[] | undefined) ?? item.task?.sourceUrls ?? []).slice(0, 5);
function submit(i: number, action: 'approve' | 'reject') {
const card = cards[i];
const btn = card?.querySelector<HTMLButtonElement>(`button[data-action="${action}"]`);
if (btn && !btn.disabled) btn.click();
}
function onKey(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const el = e.target as HTMLElement;
if (el.closest('input, textarea, select, [contenteditable]')) return;
if (data.status !== 'pending' || !data.items.length) return;
if (e.key === 'j' || e.key === 'k') {
e.preventDefault();
selected = Math.min(data.items.length - 1, Math.max(0, selected + (e.key === 'j' ? 1 : -1)));
cards[selected]?.focus();
cards[selected]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else if (e.key === 'a') {
e.preventDefault();
submit(selected, 'approve');
} else if (e.key === 'r') {
e.preventDefault();
submit(selected, 'reject');
} else if (e.key === 'e') {
e.preventDefault();
tweaking = data.items[selected]?.id ?? null;
}
}
const host = (url: string) => {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
};
</script>
<svelte:window onkeydown={onKey} />
<section class="queue" aria-labelledby="queue-h">
<div class="top">
<h2 id="queue-h">Review queue <PrivateLock size={13} /></h2>
{#if data.status === 'pending'}
<p class="keys" aria-label="Keyboard shortcuts">
<kbd>j</kbd>/<kbd>k</kbd> move · <kbd>a</kbd> approve · <kbd>e</kbd> edit &amp; approve ·
<kbd>r</kbd> reject
</p>
{/if}
</div>
<nav class="tabs" aria-label="Request status">
{#each tabs as t (t.key)}
<a
href="?status={t.key}"
class={{ on: data.status === t.key }}
aria-current={data.status === t.key ? 'page' : undefined}
>{t.label} <span class="n">{data.counts[t.key]}</span></a
>
{/each}
</nav>
{#if form?.ok}<p class="flash" role="status">{form.ok} #{form.id}.</p>{/if}
{#if !data.items.length}
<p class="empty">Nothing {data.status} right now.</p>
{:else}
<ol class="items">
{#each data.items as item, i (item.id)}
<li>
<article
class={['card', { sel: i === selected && data.status === 'pending' }]}
tabindex="-1"
bind:this={cards[i]}
onfocusin={() => (selected = i)}
aria-labelledby="req-{item.id}-h"
>
<header>
<span class="kind {item.kind}">{kindLabel[item.kind]}</span>
<h3 id="req-{item.id}-h">
{#if item.task}
<a href="/projects/worldwide-todo/{item.task.slug}">{item.task.title}</a>
{:else}
{String(item.payload.title ?? 'New task')}
{/if}
</h3>
<span class="age">#{item.id} · {relativeTime(item.createdAt, now) || 'just now'}</span
>
</header>
{#if item.stale}
<p class="warn">
<TriangleAlert size={14} aria-hidden="true" />
{item.taskDeleted
? 'Stale: the task was removed since this request was filed.'
: 'Stale: the task changed since this request was filed. The diff below is against the current version.'}
</p>
{/if}
{#if item.similar.length}
<p class="dupe">
<Copy size={14} aria-hidden="true" /> Similar:
{#each item.similar as s, j (s.slug)}{j ? ', ' : ''}<a
href="/projects/worldwide-todo/{s.slug}">{s.title}</a
>
({Math.round(s.similarity * 100)}%){/each}
</p>
{/if}
{#if diff(item).length}
<table class="diff">
<caption class="sr-only">Changes</caption>
<thead
><tr
><th scope="col">Field</th><th scope="col">Old</th><th scope="col">New</th></tr
></thead
>
<tbody>
{#each diff(item) as row (row.key)}
<tr>
<th scope="row">{FIELD_LABELS[row.key]}</th>
<td
>{#if row.old !== '—'}<del><span class="sr-only">Old: </span>{row.old}</del
>{:else}—{/if}</td
>
<td
>{#if row.new !== '—'}<ins><span class="sr-only">New: </span>{row.new}</ins
>{:else}—{/if}</td
>
</tr>
{/each}
</tbody>
</table>
{/if}
<dl class="meta">
<dt>Reason</dt>
<dd class="reason">{item.reason}</dd>
{#if item.displayName}<dt>From</dt>
<dd>{item.displayName}</dd>{/if}
{#if sources(item).length}
<dt>Sources</dt>
<dd class="srcs">
{#each sources(item) as url (url)}
<a href={url} target="_blank" rel="noopener nofollow noreferrer">{host(url)}</a>
{/each}
</dd>
{/if}
{#if item.ownerNote}<dt>Note</dt>
<dd>{item.ownerNote}</dd>{/if}
</dl>
{#if item.status === 'pending'}
{#if form?.message && form.id === item.id}
<p class="error" role="alert">{form.message}</p>
{/if}
<form
method="POST"
use:enhance={() => {
busy = item.id;
return async ({ update }) => {
await update();
busy = null;
tweaking = null;
};
}}
>
<input type="hidden" name="id" value={item.id} />
{#if tweaking === item.id}
<div class="tweak">
<TaskFieldsEditor
values={tweakValues(item)}
categories={data.categories}
id="t{item.id}"
/>
</div>
{/if}
<label class="note">
<span>Note (optional, shown on the public request page)</span>
<textarea name="note" rows="2" maxlength="1000"></textarea>
</label>
<div class="actions">
{#if tweaking === item.id}
<button
type="submit"
formaction="?/tweak"
class="primary"
disabled={busy === item.id}
><Check size={15} aria-hidden="true" />Apply tweaks &amp; approve</button
>
<button type="button" onclick={() => (tweaking = null)}>Cancel</button>
{:else}
<button
type="submit"
formaction="?/approve"
data-action="approve"
class="primary"
disabled={busy === item.id}
><Check size={15} aria-hidden="true" />Approve <kbd>a</kbd></button
>
{#if item.kind !== 'delete'}
<button type="button" onclick={() => (tweaking = item.id)}
><PencilLine size={15} aria-hidden="true" />Edit &amp; approve
<kbd>e</kbd></button
>
{/if}
{/if}
<button
type="submit"
formaction="?/reject"
data-action="reject"
class="danger"
formnovalidate
disabled={busy === item.id}
><X size={15} aria-hidden="true" />Reject <kbd>r</kbd></button
>
</div>
</form>
{:else if item.decidedAt}
<p class="decided">
{item.status === 'approved' ? 'Approved' : 'Rejected'}
{relativeTime(item.decidedAt, now) || 'just now'}
</p>
{/if}
</article>
</li>
{/each}
</ol>
{/if}
</section>
<style>
.queue {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.top {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
h2 {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 1.3rem;
font-weight: 650;
}
.keys {
font-size: 0.78rem;
color: var(--site-muted);
}
kbd {
display: inline-block;
min-width: 1.2rem;
padding: 0 0.3rem;
border: 1px solid var(--site-border);
border-bottom-width: 2px;
border-radius: 0.3rem;
font-family: inherit;
font-size: 0.72rem;
text-align: center;
line-height: 1.3;
}
.tabs {
display: flex;
gap: 0.35rem;
}
.tabs a {
padding: 0.3rem 0.75rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.83rem;
color: var(--site-muted);
text-decoration: none;
}
.tabs a:hover,
.tabs a:focus-visible {
color: var(--site-text);
border-color: var(--site-muted);
}
.tabs a.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.n {
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.flash {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--site-text);
border-radius: 0.6rem;
}
.empty {
color: var(--site-muted);
padding: 1rem 0;
}
.items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.card.sel,
.card:focus-visible {
border-color: var(--site-text);
box-shadow: inset 3px 0 0 var(--site-text);
outline: none;
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
h3 {
flex: 1;
min-width: 12rem;
font-size: 0.98rem;
font-weight: 600;
}
h3 a {
color: var(--site-text);
}
.kind {
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 650;
color: #fff;
}
.kind.add {
background: #15803d;
}
.kind.edit {
background: #1d4ed8;
}
.kind.delete {
background: #b91c1c;
}
.age,
.decided {
font-size: 0.75rem;
color: var(--site-muted);
}
.warn,
.dupe {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
}
.warn {
padding: 0.4rem 0.6rem;
border: 1px dashed #d97706;
border-radius: 0.5rem;
}
.dupe a {
color: var(--site-text);
}
.diff {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
table-layout: fixed;
}
.diff th,
.diff td {
padding: 0.35rem 0.5rem;
border-top: 1px solid var(--site-border);
text-align: left;
vertical-align: top;
white-space: pre-line;
overflow-wrap: anywhere;
}
.diff thead th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
border-top: 0;
}
.diff tbody th {
width: 7rem;
font-weight: 550;
}
del {
background: color-mix(in srgb, #dc2626 16%, transparent);
text-decoration-color: #dc2626;
}
ins {
background: color-mix(in srgb, #16a34a 18%, transparent);
text-decoration: none;
}
.meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 0.9rem;
font-size: 0.83rem;
}
.meta dt {
color: var(--site-muted);
}
.reason {
white-space: pre-line;
}
.srcs {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
}
.srcs a {
color: var(--site-text);
}
.error {
color: #dc2626;
font-size: 0.83rem;
font-weight: 550;
}
form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.tweak {
padding: 0.8rem;
border: 1px dashed var(--site-muted);
border-radius: 0.75rem;
}
.note {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.note textarea {
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-bg);
color: var(--site-text);
font-size: 0.85rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.actions button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-bg);
color: var(--site-text);
font-size: 0.84rem;
cursor: pointer;
transition:
border-color 0.15s,
transform 0.15s;
}
.actions button:hover,
.actions button:focus-visible {
border-color: var(--site-muted);
transform: translateY(-1px);
}
.actions .primary {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.actions .primary kbd {
border-color: currentColor;
}
.actions .danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.actions button:disabled {
opacity: 0.6;
cursor: progress;
}
@media (prefers-reduced-motion: reduce) {
.actions button {
transition: none;
}
.actions button:hover {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,59 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { clientIp } from '#lib/server/worldtodo/limit.js';
import { MAX_PENDING, submitRequest } from '#lib/server/worldtodo/requests.js';
import { getTask, pendingTotal } from '#lib/server/worldtodo/store.js';
import { turnstileConfigured, turnstileSiteKey } from '#lib/server/worldtodo/turnstile.js';
import { formInput } from '#lib/server/worldtodo/validate.js';
import type { Actions, PageServerLoad } from './$types';
const KINDS = ['add', 'edit', 'delete'] as const;
type Kind = (typeof KINDS)[number];
export const load: PageServerLoad = async ({ url }) => {
const raw = url.searchParams.get('kind') ?? 'add';
const kind: Kind = (KINDS as readonly string[]).includes(raw) ? (raw as Kind) : 'add';
let task = null;
if (kind !== 'add') {
const slug = url.searchParams.get('task') ?? '';
const found = slug && slug.length <= 120 ? await getTask(slug) : null;
if (!found) error(404, 'Not Found');
task = found.task;
}
return {
kind,
task,
siteKey: turnstileSiteKey(),
enabled: turnstileConfigured(),
queueFull: (await pendingTotal()) >= MAX_PENDING,
startedAt: Date.now(),
seo: {
title:
kind === 'add'
? 'Suggest a task · worldwide todo list'
: `${kind === 'edit' ? 'Suggest an edit' : 'Request removal'} · worldwide todo list`,
description: 'Suggest a change to the worldwide todo list. Every request is reviewed.',
index: false
}
};
};
export const actions: Actions = {
default: async ({ request, getClientAddress, setHeaders }) => {
const form = await request.formData();
const input = formInput(form);
const result = await submitRequest({
input,
honeypot: form.get('website'),
startedAt: form.get('started'),
token: String(form.get('cf-turnstile-response') ?? ''),
ip: clientIp(request, getClientAddress)
});
if (result.ok) redirect(303, `/projects/worldwide-todo/requests/${result.id}?new=1`);
if (result.retryAfter) setHeaders({ 'retry-after': String(result.retryAfter) });
return fail(result.status, {
message: result.message,
errors: result.errors ?? {},
values: input
});
}
};

View File

@@ -0,0 +1,299 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick, untrack } from 'svelte';
import TaskFieldsEditor from '#lib/worldtodo/TaskFieldsEditor.svelte';
import Turnstile from '#lib/worldtodo/Turnstile.svelte';
import { fieldsOf, FIELD_LABELS, type EditableField } from '#lib/worldtodo/shared';
import Send from 'lucide-svelte/icons/send';
import ShieldCheck from 'lucide-svelte/icons/shield-check';
let { data, form } = $props();
const values = $derived<Record<string, unknown>>(
form?.values ?? (data.task ? fieldsOf(data.task) : { status: 'open' })
);
const errors = $derived<Record<string, string>>(form?.errors ?? {});
// Re-render the editor and a fresh Turnstile widget after each failed attempt.
let attempt = $state(0);
let sending = $state(false);
let summary = $state<HTMLDivElement>();
// Bound (not value=) so text typed before hydration survives it; kept across failed attempts.
const sent = untrack(() => (form?.values ?? {}) as Record<string, unknown>);
let reason = $state(String(sent.reason ?? ''));
let displayName = $state(String(sent.display_name ?? ''));
$effect(() => {
if (!form?.message) return;
untrack(() => tick().then(() => summary?.focus()));
});
const heading = $derived(
data.kind === 'add'
? 'Suggest a task'
: data.kind === 'edit'
? 'Suggest an edit'
: 'Request removal'
);
const labelFor = (k: string) =>
FIELD_LABELS[k as EditableField] ??
(k === 'reason'
? 'Reason'
: k === 'display_name'
? 'Display name'
: k === 'turnstile'
? 'Anti-bot check'
: k);
</script>
<section class="wrap" aria-labelledby="form-h">
<h2 id="form-h">{heading}</h2>
{#if data.task}
<p class="about">
{data.kind === 'edit' ? 'Editing' : 'Removing'}
<a href="/projects/worldwide-todo/{data.task.slug}">{data.task.title}</a>
</p>
{/if}
{#if !data.enabled}
<p class="notice" role="status">
Requests are switched off right now. Please check back later.
</p>
{:else if data.queueFull}
<p class="notice" role="status">
The review queue is full right now. Please try again in a day or two.
</p>
{:else}
<form
method="POST"
use:enhance={() => {
sending = true;
return async ({ update, result }) => {
await update({ reset: false });
sending = false;
if (result.type === 'failure') attempt++;
};
}}
novalidate
>
{#if form?.message}
<div class="summary" role="alert" tabindex="-1" bind:this={summary}>
<strong>{form.message}</strong>
{#if Object.keys(errors).length}
<ul>
{#each Object.entries(errors) as [k, msg] (k)}
<li>{labelFor(k)}: {msg}</li>
{/each}
</ul>
{/if}
</div>
{/if}
<input type="hidden" name="kind" value={data.kind} />
{#if data.task}<input type="hidden" name="task" value={data.task.slug} />{/if}
<input type="hidden" name="started" value={data.startedAt} />
<div class="hp" aria-hidden="true">
<label>Website <input name="website" tabindex="-1" autocomplete="off" /></label>
</div>
{#if data.kind !== 'delete'}
{#key attempt}
<TaskFieldsEditor {values} {errors} categories={data.categories} />
{/key}
{/if}
<div class="field">
<label for="reason">Reason</label>
<textarea
id="reason"
name="reason"
rows="3"
maxlength="1000"
required
aria-invalid={!!errors.reason}
aria-describedby="reason-help{errors.reason ? ' reason-err' : ''}"
bind:value={reason}></textarea>
<span class="help" id="reason-help"
>{data.kind === 'delete'
? 'Why should it go? E.g. it was fixed (link it!), it is a duplicate, or it is wrong.'
: 'Why this change? 10–1000 characters, only Stan sees it.'}</span
>
{#if errors.reason}<span class="err" id="reason-err">{errors.reason}</span>{/if}
</div>
<div class="field">
<label for="display_name">Display name <span class="opt">(optional)</span></label>
<input
id="display_name"
name="display_name"
maxlength="40"
autocomplete="nickname"
bind:value={displayName}
aria-describedby="dn-help{errors.display_name ? ' dn-err' : ''}"
/>
<span class="help" id="dn-help">Shown nowhere except the review queue.</span>
{#if errors.display_name}<span class="err" id="dn-err">{errors.display_name}</span>{/if}
</div>
<div class="field">
<span class="label"><ShieldCheck size={14} aria-hidden="true" /> Anti-bot check</span>
{#key attempt}
<Turnstile siteKey={data.siteKey} describedby={errors.turnstile ? 'ts-err' : undefined} />
{/key}
{#if errors.turnstile}<span class="err" id="ts-err">{errors.turnstile}</span>{/if}
</div>
<button class="submit" type="submit" disabled={sending}
><Send size={15} aria-hidden="true" />{sending ? 'Sending…' : 'Send for review'}</button
>
<p class="privacy">
Privacy: we store your request and the optional display name, nothing else: no IP address,
email or browser details. Spam protection by Cloudflare Turnstile. Rate limits use a salted,
daily-rotating hash of your IP that is kept in memory only.
</p>
</form>
{/if}
</section>
<style>
.wrap {
max-width: 44rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
h2 {
font-size: 1.3rem;
font-weight: 650;
letter-spacing: -0.01em;
}
.about {
font-size: 0.9rem;
color: var(--site-muted);
}
.about a {
color: var(--site-text);
}
.notice {
padding: 0.75rem 0.9rem;
border: 1px solid var(--site-text);
border-radius: 0.75rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1.1rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
background: var(--site-surface);
}
.summary {
padding: 0.7rem 0.85rem;
border: 1px solid #dc2626;
border-left-width: 4px;
border-radius: 0.6rem;
font-size: 0.85rem;
}
.summary:focus {
outline: 2px solid var(--site-text);
outline-offset: 2px;
}
.summary ul {
margin-top: 0.3rem;
list-style: disc;
padding-left: 1.1rem;
}
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
label,
.label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.82rem;
font-weight: 550;
}
.opt {
font-weight: 400;
color: var(--site-muted);
}
textarea,
input {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-bg);
color: var(--site-text);
font-size: 0.9rem;
}
textarea:focus-visible,
input:focus-visible {
outline: 2px solid var(--site-text);
outline-offset: 1px;
}
[aria-invalid='true'] {
border-color: #dc2626;
}
.help,
.privacy {
font-size: 0.75rem;
color: var(--site-muted);
}
.err {
font-size: 0.78rem;
color: #dc2626;
font-weight: 550;
}
.err::before {
content: '⚠ ';
}
.submit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
width: fit-content;
padding: 0.6rem 1.1rem;
border-radius: 0.7rem;
background: var(--site-text);
color: var(--site-bg);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition:
transform 0.15s,
box-shadow 0.15s;
}
.submit:hover,
.submit:focus-visible {
transform: translateY(-1px);
box-shadow: 0 6px 20px -10px rgb(0 0 0 / 0.6);
}
.submit:focus-visible {
outline: 2px solid var(--site-text);
outline-offset: 2px;
}
.submit:disabled {
opacity: 0.6;
cursor: progress;
}
@media (prefers-reduced-motion: reduce) {
.submit {
transition: none;
}
.submit:hover {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,20 @@
import { error } from '@sveltejs/kit';
import { requestStatus } from '#lib/server/worldtodo/store.js';
import type { PageServerLoad } from './$types';
// Public request status: kind, status, dates and the owner's note once decided. Never the
// reason, the payload or the display name.
export const load: PageServerLoad = async ({ params, url }) => {
if (!/^\d{1,12}$/.test(params.id)) error(404, 'Not Found');
const req = await requestStatus(Number(params.id));
if (!req) error(404, 'Not Found');
return {
req,
justSent: url.searchParams.has('new'),
seo: {
title: `Request #${req.id} · worldwide todo list`,
description: 'Request status',
index: false
}
};
};

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import CircleCheck from 'lucide-svelte/icons/circle-check';
import CircleX from 'lucide-svelte/icons/circle-x';
import Hourglass from 'lucide-svelte/icons/hourglass';
let { data } = $props();
const r = $derived(data.req);
const kind = { add: 'New task', edit: 'Edit', delete: 'Removal' } as const;
const fmt = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam',
timeZoneName: 'short'
});
</script>
<section class="wrap" aria-labelledby="req-h">
{#if data.justSent && r.status === 'pending'}
<p class="thanks" role="status">Thanks, your request is waiting for review.</p>
{/if}
<h2 id="req-h">Request #{r.id}</h2>
<div class="card">
<span class="state {r.status}">
{#if r.status === 'approved'}<CircleCheck size={18} aria-hidden="true" />Approved
{:else if r.status === 'rejected'}<CircleX size={18} aria-hidden="true" />Not accepted
{:else}<Hourglass size={18} aria-hidden="true" />Waiting for review{/if}
</span>
<dl>
<dt>Type</dt>
<dd>{kind[r.kind]}</dd>
{#if r.task}
<dt>Task</dt>
<dd><a href="/projects/worldwide-todo/{r.task.slug}">{r.task.title}</a></dd>
{/if}
<dt>Sent</dt>
<dd><time datetime={r.createdAt}>{fmt(r.createdAt)}</time></dd>
{#if r.decidedAt}
<dt>Decided</dt>
<dd><time datetime={r.decidedAt}>{fmt(r.decidedAt)}</time></dd>
{/if}
{#if r.ownerNote}
<dt>Note</dt>
<dd>{r.ownerNote}</dd>
{/if}
</dl>
</div>
<p class="help">
Bookmark this page to check back. <a href="/projects/worldwide-todo">Back to the list</a>
</p>
</section>
<style>
.wrap {
max-width: 36rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.thanks {
padding: 0.7rem 0.9rem;
border: 1px solid var(--site-text);
border-radius: 0.75rem;
font-weight: 550;
}
h2 {
font-size: 1.3rem;
font-weight: 650;
}
.card {
padding: 1rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
background: var(--site-surface);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.state {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
}
.approved :global(svg) {
color: #16a34a;
}
.rejected :global(svg) {
color: #dc2626;
}
.pending :global(svg) {
color: #d97706;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.35rem 1rem;
font-size: 0.88rem;
}
dt {
color: var(--site-muted);
}
dd a {
color: var(--site-text);
}
.help {
font-size: 0.8rem;
color: var(--site-muted);
}
.help a {
color: var(--site-text);
}
</style>