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:
41
src/lib/worldtodo/IconTile.svelte
Normal file
41
src/lib/worldtodo/IconTile.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
// A small coloured square with a white icon (decorative; always next to a text label).
|
||||
let {
|
||||
icon: Icon,
|
||||
background,
|
||||
size = 'md'
|
||||
}: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
icon: Component<any>;
|
||||
background: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
} = $props();
|
||||
const px = $derived(size === 'lg' ? 22 : size === 'sm' ? 12 : 15);
|
||||
</script>
|
||||
|
||||
<span class="tile {size}" style:background aria-hidden="true"><Icon size={px} /></span>
|
||||
|
||||
<style>
|
||||
.tile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
}
|
||||
.sm {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.lg {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
39
src/lib/worldtodo/StatusPill.svelte
Normal file
39
src/lib/worldtodo/StatusPill.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { STATUS_LABELS, type Status } from './shared';
|
||||
let { status }: { status: Status } = $props();
|
||||
</script>
|
||||
|
||||
<span class="pill {status}">{STATUS_LABELS[status]}</span>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 550;
|
||||
white-space: nowrap;
|
||||
color: var(--site-text);
|
||||
}
|
||||
.pill::before {
|
||||
content: '';
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid currentColor;
|
||||
}
|
||||
.in_progress::before {
|
||||
background: linear-gradient(90deg, currentColor 50%, transparent 50%);
|
||||
}
|
||||
.done {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.done::before {
|
||||
background: currentColor;
|
||||
}
|
||||
</style>
|
||||
125
src/lib/worldtodo/TaskCard.svelte
Normal file
125
src/lib/worldtodo/TaskCard.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import IconTile from './IconTile.svelte';
|
||||
import StatusPill from './StatusPill.svelte';
|
||||
import { SCALE_ICONS, categoryIcon } from './icons';
|
||||
import { SCALE_INFO, relativeTime, type Category, type PublicTask } from './shared';
|
||||
|
||||
let {
|
||||
task,
|
||||
category,
|
||||
flash = false,
|
||||
now
|
||||
}: { task: PublicTask; category: Category | undefined; flash?: boolean; now: number } = $props();
|
||||
const scale = $derived(SCALE_ICONS[task.scale]);
|
||||
</script>
|
||||
|
||||
<a
|
||||
class={['card', { flash }]}
|
||||
href={resolve('/projects/worldwide-todo/[slug]', { slug: task.slug })}
|
||||
data-sveltekit-preload-data="hover"
|
||||
>
|
||||
<span class="icons">
|
||||
<IconTile icon={scale.icon} background={scale.gradient} />
|
||||
<IconTile icon={categoryIcon(category?.icon)} background={category?.colour ?? '#737373'} />
|
||||
<span class="meta">{SCALE_INFO[task.scale].label} · {category?.label ?? task.category}</span>
|
||||
<StatusPill status={task.status} />
|
||||
</span>
|
||||
<h3>{task.title}</h3>
|
||||
<span class="where">{task.locationName}</span>
|
||||
<span class="desc">{task.description}</span>
|
||||
<span class="foot">
|
||||
<span>updated {relativeTime(task.updatedAt, now) || 'just now'}</span>
|
||||
<span>{task.sourceUrls.length} source{task.sourceUrls.length === 1 ? '' : 's'}</span>
|
||||
{#if task.pending}<span class="pending">{task.pending} pending</span>{/if}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
height: 100%;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.85rem;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s,
|
||||
background-color 0.6s;
|
||||
}
|
||||
.card:hover,
|
||||
.card:focus-visible {
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--site-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.flash {
|
||||
background: color-mix(in srgb, var(--site-surface) 80%, #facc15);
|
||||
}
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
h3 {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.where {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.4;
|
||||
color: var(--site-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.pending {
|
||||
color: var(--site-text);
|
||||
font-weight: 550;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
.card:hover,
|
||||
.card:focus-visible {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
71
src/lib/worldtodo/Turnstile.svelte
Normal file
71
src/lib/worldtodo/Turnstile.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts" module>
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
let loading: Promise<void> | null = null;
|
||||
function load() {
|
||||
loading ??= new Promise((ok, fail) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
s.async = true;
|
||||
s.onload = () => ok();
|
||||
s.onerror = () => {
|
||||
loading = null;
|
||||
fail(new Error('turnstile'));
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return loading;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Cloudflare Turnstile, rendered explicitly so it survives client-side navigation. The
|
||||
// widget adds its own hidden `cf-turnstile-response` input to the surrounding form.
|
||||
let { siteKey, describedby }: { siteKey: string; describedby?: string } = $props();
|
||||
let el: HTMLDivElement;
|
||||
let failed = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
let id: string | undefined;
|
||||
let gone = false;
|
||||
load()
|
||||
.then(() => {
|
||||
if (gone || !window.turnstile) return;
|
||||
id = window.turnstile.render(el, {
|
||||
sitekey: siteKey,
|
||||
theme: 'auto',
|
||||
'response-field-name': 'cf-turnstile-response'
|
||||
});
|
||||
})
|
||||
.catch(() => (failed = true));
|
||||
return () => {
|
||||
gone = true;
|
||||
if (id && window.turnstile) window.turnstile.remove(id);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={el} class="turnstile" aria-describedby={describedby}></div>
|
||||
{#if failed}
|
||||
<p class="fail">
|
||||
The anti-bot check couldn't load. Check your connection or blockers and reload.
|
||||
</p>
|
||||
{/if}
|
||||
<noscript><p class="fail">The anti-bot check needs JavaScript.</p></noscript>
|
||||
|
||||
<style>
|
||||
.turnstile {
|
||||
min-height: 65px;
|
||||
}
|
||||
.fail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
</style>
|
||||
52
src/lib/worldtodo/icons.ts
Normal file
52
src/lib/worldtodo/icons.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Component } from 'svelte';
|
||||
import Bike from 'lucide-svelte/icons/bike';
|
||||
import Building2 from 'lucide-svelte/icons/building-2';
|
||||
import Construction from 'lucide-svelte/icons/construction';
|
||||
import Droplet from 'lucide-svelte/icons/droplet';
|
||||
import Earth from 'lucide-svelte/icons/earth';
|
||||
import Flag from 'lucide-svelte/icons/flag';
|
||||
import HandHeart from 'lucide-svelte/icons/hand-heart';
|
||||
import HeartPulse from 'lucide-svelte/icons/heart-pulse';
|
||||
import House from 'lucide-svelte/icons/house';
|
||||
import Landmark from 'lucide-svelte/icons/landmark';
|
||||
import Leaf from 'lucide-svelte/icons/leaf';
|
||||
import ListTodo from 'lucide-svelte/icons/list-todo';
|
||||
import MapIcon from 'lucide-svelte/icons/map';
|
||||
import MapPin from 'lucide-svelte/icons/map-pin';
|
||||
import Recycle from 'lucide-svelte/icons/recycle';
|
||||
import ShieldAlert from 'lucide-svelte/icons/shield-alert';
|
||||
import Thermometer from 'lucide-svelte/icons/thermometer';
|
||||
import Wheat from 'lucide-svelte/icons/wheat';
|
||||
import Zap from 'lucide-svelte/icons/zap';
|
||||
import type { Scale } from './shared';
|
||||
|
||||
// Colourful icons on the black-and-white base: categories use the icon key and accent colour
|
||||
// stored in worldtodo.categories, scales have fixed gradients. Always paired with a text label.
|
||||
|
||||
type Icon = typeof Earth | Component;
|
||||
|
||||
export const CATEGORY_ICONS: Record<string, Icon> = {
|
||||
thermometer: Thermometer,
|
||||
leaf: Leaf,
|
||||
zap: Zap,
|
||||
wheat: Wheat,
|
||||
landmark: Landmark,
|
||||
'heart-pulse': HeartPulse,
|
||||
house: House,
|
||||
'hand-heart': HandHeart,
|
||||
construction: Construction,
|
||||
bike: Bike,
|
||||
'shield-alert': ShieldAlert,
|
||||
recycle: Recycle,
|
||||
droplet: Droplet
|
||||
};
|
||||
export const categoryIcon = (key: string | undefined): Icon =>
|
||||
(key && CATEGORY_ICONS[key]) || ListTodo;
|
||||
|
||||
export const SCALE_ICONS: Record<Scale, { icon: Icon; gradient: string }> = {
|
||||
planet: { icon: Earth, gradient: 'linear-gradient(135deg, #38bdf8, #16a34a)' },
|
||||
region: { icon: MapIcon, gradient: 'linear-gradient(135deg, #a78bfa, #4f46e5)' },
|
||||
country: { icon: Flag, gradient: 'linear-gradient(135deg, #fb7185, #dc2626)' },
|
||||
city: { icon: Building2, gradient: 'linear-gradient(135deg, #fbbf24, #ea580c)' },
|
||||
street: { icon: MapPin, gradient: 'linear-gradient(135deg, #2dd4bf, #0f766e)' }
|
||||
};
|
||||
54
src/lib/worldtodo/live.ts
Normal file
54
src/lib/worldtodo/live.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Browser side of the worldtodo realtime channels: connect to /ws/<channel>, reconnect with
|
||||
// backoff (1 s → 30 s), and report whether the socket is up so pages can fall back to polling.
|
||||
// Returns a stop function (call it from an $effect cleanup).
|
||||
|
||||
export function subscribe(
|
||||
channel: 'worldtodo' | 'worldtodo-owner',
|
||||
onMessage: (msg: unknown) => void,
|
||||
onState: (up: boolean) => void = () => {}
|
||||
): () => void {
|
||||
let ws: WebSocket | null = null;
|
||||
let stopped = false;
|
||||
let delay = 1000;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const connect = () => {
|
||||
if (stopped) return;
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
try {
|
||||
ws = new WebSocket(`${proto}//${location.host}/ws/${channel}`);
|
||||
} catch {
|
||||
return retry();
|
||||
}
|
||||
ws.onopen = () => {
|
||||
delay = 1000;
|
||||
onState(true);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
onMessage(JSON.parse(String(e.data)));
|
||||
} catch {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
onState(false);
|
||||
retry();
|
||||
};
|
||||
};
|
||||
const retry = () => {
|
||||
if (stopped) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(connect, delay + Math.random() * 500);
|
||||
delay = Math.min(delay * 2, 30_000);
|
||||
};
|
||||
connect();
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
83
src/lib/worldtodo/shared.test.ts
Normal file
83
src/lib/worldtodo/shared.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
WORLDTODO_REQUEST_KINDS,
|
||||
WORLDTODO_REQUEST_STATUSES,
|
||||
WORLDTODO_SCALES,
|
||||
WORLDTODO_STATUSES,
|
||||
changedFields,
|
||||
matchesFilters,
|
||||
type PublicTask,
|
||||
type TaskFields
|
||||
} from './shared';
|
||||
import {
|
||||
worldtodoRequestKind,
|
||||
worldtodoRequestStatus,
|
||||
worldtodoScale,
|
||||
worldtodoStatus
|
||||
} from '#lib/server/db/schema/worldtodo.js';
|
||||
import seed from '../../../data/worldtodo-seed.json';
|
||||
|
||||
const base: TaskFields = {
|
||||
title: 'Repair the bridge',
|
||||
description: 'The bridge is broken and needs fixing.',
|
||||
scale: 'city',
|
||||
category: 'infrastructure',
|
||||
location_name: 'Gouda',
|
||||
country_code: 'NL',
|
||||
lat: 52,
|
||||
lon: 4.7,
|
||||
status: 'open',
|
||||
source_urls: ['https://example.org/a']
|
||||
};
|
||||
|
||||
describe('worldtodo shared', () => {
|
||||
it('keeps the schema enums in sync with the shared constants', () => {
|
||||
expect(worldtodoScale.enumValues).toEqual([...WORLDTODO_SCALES]);
|
||||
expect(worldtodoStatus.enumValues).toEqual([...WORLDTODO_STATUSES]);
|
||||
expect(worldtodoRequestKind.enumValues).toEqual([...WORLDTODO_REQUEST_KINDS]);
|
||||
expect(worldtodoRequestStatus.enumValues).toEqual([...WORLDTODO_REQUEST_STATUSES]);
|
||||
});
|
||||
|
||||
it('returns only the changed fields', () => {
|
||||
expect(changedFields(base, { ...base })).toEqual({});
|
||||
expect(changedFields(base, { ...base, status: 'done', lat: 52 })).toEqual({ status: 'done' });
|
||||
expect(
|
||||
changedFields(base, { ...base, source_urls: ['https://example.org/a', 'https://b.org'] })
|
||||
).toEqual({ source_urls: ['https://example.org/a', 'https://b.org'] });
|
||||
expect(changedFields(base, { country_code: null })).toEqual({ country_code: null });
|
||||
});
|
||||
|
||||
it('matches list filters like the server', () => {
|
||||
const t = {
|
||||
slug: 'x',
|
||||
title: 'Repair the bridge',
|
||||
description: 'd',
|
||||
locationName: 'Gouda',
|
||||
scale: 'city',
|
||||
category: 'infrastructure',
|
||||
status: 'done'
|
||||
} as PublicTask;
|
||||
const f = { scale: '', category: '', status: 'todo', q: '' };
|
||||
expect(matchesFilters(t, f)).toBe(false);
|
||||
expect(matchesFilters(t, { ...f, status: 'all' })).toBe(true);
|
||||
expect(matchesFilters(t, { ...f, status: 'done', q: 'gouda bridge' })).toBe(true);
|
||||
expect(matchesFilters(t, { ...f, status: 'all', scale: 'planet' })).toBe(false);
|
||||
});
|
||||
|
||||
it('ships the full, sourced seed (68 tasks with the expected spread)', () => {
|
||||
const tasks = seed.tasks;
|
||||
expect(tasks).toHaveLength(68);
|
||||
expect(new Set(tasks.map((t) => t.slug)).size).toBe(68);
|
||||
const by = (k: 'scale' | 'status') =>
|
||||
tasks.reduce<Record<string, number>>((a, t) => ((a[t[k]] = (a[t[k]] ?? 0) + 1), a), {});
|
||||
expect(by('scale')).toEqual({ planet: 13, region: 12, country: 12, city: 12, street: 19 });
|
||||
expect(by('status')).toEqual({ open: 58, in_progress: 10 });
|
||||
for (const t of tasks) {
|
||||
expect(t.source_urls.length).toBeGreaterThan(0);
|
||||
expect(seed.categories).toContain(t.category);
|
||||
expect(['request', 'requests', 'queue', 'api']).not.toContain(t.slug);
|
||||
expect(t.title.length).toBeGreaterThanOrEqual(5);
|
||||
expect(t.title.length).toBeLessThanOrEqual(140);
|
||||
}
|
||||
});
|
||||
});
|
||||
180
src/lib/worldtodo/shared.ts
Normal file
180
src/lib/worldtodo/shared.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// Shared (client + server) constants and types for the Worldwide Todo List.
|
||||
|
||||
export const WORLDTODO_SCALES = ['planet', 'region', 'country', 'city', 'street'] as const;
|
||||
export type Scale = (typeof WORLDTODO_SCALES)[number];
|
||||
export const WORLDTODO_STATUSES = ['open', 'in_progress', 'done'] as const;
|
||||
export type Status = (typeof WORLDTODO_STATUSES)[number];
|
||||
export const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
|
||||
export type RequestKind = (typeof WORLDTODO_REQUEST_KINDS)[number];
|
||||
export const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
|
||||
export type RequestStatus = (typeof WORLDTODO_REQUEST_STATUSES)[number];
|
||||
|
||||
/** The only keys a request payload may carry (checked server-side). */
|
||||
export const EDITABLE_FIELDS = [
|
||||
'title',
|
||||
'description',
|
||||
'scale',
|
||||
'category',
|
||||
'location_name',
|
||||
'country_code',
|
||||
'lat',
|
||||
'lon',
|
||||
'status',
|
||||
'source_urls'
|
||||
] as const;
|
||||
export type EditableField = (typeof EDITABLE_FIELDS)[number];
|
||||
|
||||
export const FIELD_LABELS: Record<EditableField, string> = {
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
scale: 'Scale',
|
||||
category: 'Category',
|
||||
location_name: 'Location',
|
||||
country_code: 'Country code',
|
||||
lat: 'Latitude',
|
||||
lon: 'Longitude',
|
||||
status: 'Status',
|
||||
source_urls: 'Sources'
|
||||
};
|
||||
|
||||
export const SCALE_INFO: Record<Scale, { label: string; emoji: string }> = {
|
||||
planet: { label: 'Planet', emoji: '🌍' },
|
||||
region: { label: 'Region', emoji: '🗺️' },
|
||||
country: { label: 'Country', emoji: '🏳️' },
|
||||
city: { label: 'City', emoji: '🏙️' },
|
||||
street: { label: 'Street', emoji: '🛣️' }
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<Status, string> = {
|
||||
open: 'Open',
|
||||
in_progress: 'In progress',
|
||||
done: 'Done'
|
||||
};
|
||||
|
||||
/** Slugs that are routes under /projects/worldwide-todo, never task slugs. */
|
||||
export const RESERVED_SLUGS = new Set(['request', 'requests', 'queue', 'api']);
|
||||
|
||||
/** A task as the public sees it (list, detail, realtime events). */
|
||||
export type PublicTask = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
scale: Scale;
|
||||
category: string;
|
||||
locationName: string;
|
||||
countryCode: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
status: Status;
|
||||
sourceUrls: string[];
|
||||
sourceDate: string | null;
|
||||
confidence: 'high' | 'medium';
|
||||
verificationNote: string | null;
|
||||
asOf: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
pending?: number;
|
||||
};
|
||||
|
||||
export type Category = { slug: string; label: string; icon: string; colour: string };
|
||||
|
||||
export type LiveEvent = {
|
||||
type: 'task.added' | 'task.updated' | 'task.deleted';
|
||||
slug: string;
|
||||
task: PublicTask | null;
|
||||
at: string;
|
||||
};
|
||||
|
||||
/** Field values in the editable shape (snake_case keys, as in payloads). */
|
||||
export type TaskFields = {
|
||||
title: string;
|
||||
description: string;
|
||||
scale: Scale;
|
||||
category: string;
|
||||
location_name: string;
|
||||
country_code: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
status: Status;
|
||||
source_urls: string[];
|
||||
};
|
||||
|
||||
export function fieldsOf(task: PublicTask): TaskFields {
|
||||
return {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
scale: task.scale,
|
||||
category: task.category,
|
||||
location_name: task.locationName,
|
||||
country_code: task.countryCode,
|
||||
lat: task.lat,
|
||||
lon: task.lon,
|
||||
status: task.status,
|
||||
source_urls: task.sourceUrls
|
||||
};
|
||||
}
|
||||
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
/** Only the fields of `next` that differ from `current`. */
|
||||
export function changedFields(current: TaskFields, next: Partial<TaskFields>): Partial<TaskFields> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of EDITABLE_FIELDS) {
|
||||
if (key in next && !same(current[key], next[key])) out[key] = next[key];
|
||||
}
|
||||
return out as Partial<TaskFields>;
|
||||
}
|
||||
|
||||
export function formatValue(key: EditableField, value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (key === 'source_urls' && Array.isArray(value)) return value.join('\n');
|
||||
if (key === 'status') return STATUS_LABELS[value as Status] ?? String(value);
|
||||
if (key === 'scale') return SCALE_INFO[value as Scale]?.label ?? String(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Does a task match the list filters (used for live updates on open list pages)? */
|
||||
export function matchesFilters(
|
||||
task: PublicTask,
|
||||
f: { scale: string; category: string; status: string; q: string }
|
||||
): boolean {
|
||||
if (f.scale && task.scale !== f.scale) return false;
|
||||
if (f.category && task.category !== f.category) return false;
|
||||
if (f.status === 'todo' && task.status === 'done') return false;
|
||||
if (['open', 'in_progress', 'done'].includes(f.status) && task.status !== f.status) return false;
|
||||
if (f.q) {
|
||||
const hay = `${task.title} ${task.description} ${task.locationName}`.toLowerCase();
|
||||
if (
|
||||
!f.q
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.every((w) => hay.includes(w))
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function relativeTime(iso: string, now = Date.now()): string {
|
||||
const s = Math.round((now - new Date(iso).getTime()) / 1000);
|
||||
if (s < 60) return 'just now';
|
||||
const units: [number, string][] = [
|
||||
[60, 'minute'],
|
||||
[3600, 'hour'],
|
||||
[86400, 'day'],
|
||||
[2592000, 'month'],
|
||||
[31536000, 'year']
|
||||
];
|
||||
let label = '';
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
const [size, name] = units[i];
|
||||
if (s >= size) {
|
||||
const n = Math.floor(s / size);
|
||||
label = `${n} ${name}${n === 1 ? '' : 's'} ago`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
Reference in New Issue
Block a user