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:
401
src/lib/worldtodo/TaskFieldsEditor.svelte
Normal file
401
src/lib/worldtodo/TaskFieldsEditor.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user