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
Some checks failed
Deploy production / deploy (push) Failing after 9s
This commit is contained in:
299
src/routes/projects/worldwide-todo/request/+page.svelte
Normal file
299
src/routes/projects/worldwide-todo/request/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user