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,401 @@
<script lang="ts">
import { untrack } from 'svelte';
import IconTile from './IconTile.svelte';
import { SCALE_ICONS } from './icons';
import {
SCALE_INFO,
STATUS_LABELS,
WORLDTODO_SCALES,
WORLDTODO_STATUSES,
type Category
} from './shared';
import Plus from 'lucide-svelte/icons/plus';
import X from 'lucide-svelte/icons/x';
// The task fields as form inputs (request form and the queue's "edit & approve"). Names
// match the request payload keys; source_urls repeats.
type Values = Record<string, unknown>;
let {
values,
errors = {},
categories,
id = 'f'
}: {
values: Values;
errors?: Record<string, string>;
categories: Category[];
id?: string;
} = $props();
const str = (v: unknown) => (v === null || v === undefined ? '' : String(v));
// The form owns its values after mount (the parent keys it per attempt), so the props are
// only read once here.
const init = untrack(() => ({ values, errors }));
let title = $state(str(init.values.title));
let description = $state(str(init.values.description));
// Everything is bound so text typed before hydration survives it.
let scale = $state(str(init.values.scale));
let category = $state(str(init.values.category));
let status = $state(str(init.values.status) || 'open');
let locationName = $state(str(init.values.location_name));
let countryCode = $state(str(init.values.country_code));
let lat = $state(str(init.values.lat));
let lon = $state(str(init.values.lon));
const initialUrls = Array.isArray(init.values.source_urls)
? (init.values.source_urls as string[]).filter(Boolean)
: [];
let urls = $state<string[]>(
initialUrls.length < 5 ? [...initialUrls, ''] : initialUrls.slice(0, 5)
);
const err = (k: string) => errors[k];
const described = (k: string, help = false) =>
[help ? `${id}-${k}-help` : '', err(k) ? `${id}-${k}-err` : ''].filter(Boolean).join(' ') ||
undefined;
const hasLatLon = !!(
str(init.values.lat) ||
str(init.values.lon) ||
init.errors.lat ||
init.errors.lon
);
</script>
<div class="fields">
<div class="field">
<label for="{id}-title">Title</label>
<input
id="{id}-title"
name="title"
bind:value={title}
maxlength="140"
required
aria-invalid={!!err('title')}
aria-describedby={described('title', true)}
/>
<span class="help" id="{id}-title-help"
>Phrase it as something to do, e.g. “Repair the …”. <span class="count"
>{title.length}/140</span
></span
>
{#if err('title')}<span class="err" id="{id}-title-err">{err('title')}</span>{/if}
</div>
<div class="field">
<label for="{id}-description">Description</label>
<textarea
id="{id}-description"
name="description"
bind:value={description}
rows="5"
maxlength="2000"
required
aria-invalid={!!err('description')}
aria-describedby={described('description', true)}></textarea>
<span class="help" id="{id}-description-help"
>What is wrong, what would “done” look like, and who could do it. Plain text. <span
class="count">{description.length}/2000</span
></span
>
{#if err('description')}<span class="err" id="{id}-description-err">{err('description')}</span
>{/if}
</div>
<fieldset class="field" aria-describedby={described('scale')}>
<legend>Scale</legend>
<div class="scales">
{#each WORLDTODO_SCALES as s (s)}
<label class="scale">
<input type="radio" name="scale" value={s} bind:group={scale} required />
<IconTile icon={SCALE_ICONS[s].icon} background={SCALE_ICONS[s].gradient} />
<span>{SCALE_INFO[s].label}</span>
</label>
{/each}
</div>
{#if err('scale')}<span class="err" id="{id}-scale-err">{err('scale')}</span>{/if}
</fieldset>
<div class="row">
<div class="field">
<label for="{id}-category">Category</label>
<select
id="{id}-category"
name="category"
bind:value={category}
required
aria-invalid={!!err('category')}
aria-describedby={described('category')}
>
<option value="" disabled>Pick one</option>
{#each categories as c (c.slug)}
<option value={c.slug}>{c.label}</option>
{/each}
</select>
{#if err('category')}<span class="err" id="{id}-category-err">{err('category')}</span>{/if}
</div>
<div class="field">
<label for="{id}-status">Status</label>
<select
id="{id}-status"
name="status"
bind:value={status}
aria-describedby={described('status')}
>
{#each WORLDTODO_STATUSES as s (s)}
<option value={s}>{STATUS_LABELS[s]}</option>
{/each}
</select>
{#if err('status')}<span class="err" id="{id}-status-err">{err('status')}</span>{/if}
</div>
</div>
<div class="row">
<div class="field grow">
<label for="{id}-location">Location</label>
<input
id="{id}-location"
name="location_name"
bind:value={locationName}
maxlength="120"
required
placeholder="e.g. Rotterdam, Netherlands"
aria-invalid={!!err('location_name')}
aria-describedby={described('location_name')}
/>
{#if err('location_name')}<span class="err" id="{id}-location_name-err"
>{err('location_name')}</span
>{/if}
</div>
<div class="field small">
<label for="{id}-cc">Country code</label>
<input
id="{id}-cc"
name="country_code"
bind:value={countryCode}
maxlength="2"
placeholder="NL"
autocomplete="off"
aria-invalid={!!err('country_code')}
aria-describedby={described('country_code')}
/>
{#if err('country_code')}<span class="err" id="{id}-country_code-err"
>{err('country_code')}</span
>{/if}
</div>
</div>
<details class="adv" open={hasLatLon}>
<summary>Map position (optional)</summary>
<div class="row">
<div class="field">
<label for="{id}-lat">Latitude</label>
<input
id="{id}-lat"
name="lat"
inputmode="decimal"
bind:value={lat}
placeholder="51.92"
aria-invalid={!!err('lat')}
aria-describedby={described('lat')}
/>
{#if err('lat')}<span class="err" id="{id}-lat-err">{err('lat')}</span>{/if}
</div>
<div class="field">
<label for="{id}-lon">Longitude</label>
<input
id="{id}-lon"
name="lon"
inputmode="decimal"
bind:value={lon}
placeholder="4.48"
aria-invalid={!!err('lon')}
aria-describedby={described('lon')}
/>
{#if err('lon')}<span class="err" id="{id}-lon-err">{err('lon')}</span>{/if}
</div>
</div>
</details>
<fieldset class="field" aria-describedby={described('source_urls', true)}>
<legend>Sources (1–5 links)</legend>
<span class="help" id="{id}-source_urls-help"
>A news article, report or official page that backs this up.</span
>
{#each urls, i (i)}
<div class="url">
<input
type="url"
name="source_urls"
bind:value={urls[i]}
maxlength="500"
placeholder="https://…"
aria-label="Source link {i + 1}"
required={i === 0}
aria-invalid={!!err('source_urls')}
/>
{#if urls.length > 1}
<button
type="button"
class="icon-btn"
aria-label="Remove link {i + 1}"
onclick={() => urls.splice(i, 1)}><X size={14} aria-hidden="true" /></button
>
{/if}
</div>
{/each}
{#if urls.length < 5}
<button type="button" class="add" onclick={() => urls.push('')}
><Plus size={14} aria-hidden="true" />another link</button
>
{/if}
{#if err('source_urls')}<span class="err" id="{id}-source_urls-err">{err('source_urls')}</span
>{/if}
</fieldset>
</div>
<style>
.fields {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
border: 0;
padding: 0;
margin: 0;
}
label,
legend,
summary {
font-size: 0.82rem;
font-weight: 550;
}
legend {
margin-bottom: 0.3rem;
}
input:not([type='radio']),
textarea,
select {
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;
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--site-text);
outline-offset: 1px;
}
[aria-invalid='true'] {
border-color: #dc2626 !important;
}
.help {
font-size: 0.75rem;
color: var(--site-muted);
}
.count {
float: right;
font-variant-numeric: tabular-nums;
}
.err {
font-size: 0.78rem;
color: #dc2626;
font-weight: 550;
}
.err::before {
content: '⚠ ';
}
.scales {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
gap: 0.4rem;
}
.scale {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.55rem;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
cursor: pointer;
font-weight: 500;
transition:
border-color 0.15s,
transform 0.15s;
}
.scale:hover {
border-color: var(--site-muted);
transform: translateY(-1px);
}
.scale:has(input:checked) {
border-color: var(--site-text);
box-shadow: inset 0 0 0 1px var(--site-text);
}
.scale:has(input:focus-visible) {
outline: 2px solid var(--site-text);
outline-offset: 2px;
}
.scale input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.row:has(.grow) {
grid-template-columns: minmax(0, 1fr) 7rem;
}
.adv summary {
cursor: pointer;
color: var(--site-muted);
}
.adv[open] summary {
margin-bottom: 0.5rem;
}
.url {
display: flex;
gap: 0.35rem;
}
.icon-btn,
.add {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
font-size: 0.8rem;
color: var(--site-muted);
background: transparent;
cursor: pointer;
width: fit-content;
}
.icon-btn:hover,
.add:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
@media (max-width: 520px) {
.row,
.row:has(.grow) {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.scale {
transition: none;
}
.scale:hover {
transform: none;
}
}
</style>