All checks were successful
Deploy production / deploy (push) Successful in 1m13s
404 lines
10 KiB
Svelte
404 lines
10 KiB
Svelte
<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>
|