Files
website/src/routes/projects/worldwide-todo/request/+page.svelte

300 lines
7.5 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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