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,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>