300 lines
7.5 KiB
Svelte
300 lines
7.5 KiB
Svelte
<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>
|