Files
website/src/lib/worldtodo/TaskFieldsEditor.svelte
2026-10-26 08:38:51 +00:00

404 lines
10 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 { untrack } from 'svelte';
import IconTile from './IconTile.svelte';
import MapPicker from './MapPicker.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>
<MapPicker bind:lat bind:lon />
</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>