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:
@@ -30,5 +30,8 @@ export const variables = defineEnvVars({
|
||||
UBI_EMAIL: { schema: (input) => input ?? '' },
|
||||
UBI_PASSWORD: { schema: (input) => input ?? '' },
|
||||
GAMES_POLLER: { schema: (input) => input ?? '' },
|
||||
DB_URL: { schema: (input) => input ?? '' }
|
||||
DB_URL: { schema: (input) => input ?? '' },
|
||||
// Cloudflare Turnstile for the worldwide todo list request form.
|
||||
TURNSTILE_SECRET_KEY: { schema: (input) => input ?? '' },
|
||||
PUBLIC_TURNSTILE_SITE_KEY: { public: true, schema: (input) => input ?? '' }
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startMusicPoller } from '#lib/server/music/poller.js';
|
||||
import { startGamesPoller } from '#lib/server/games/poller.js';
|
||||
import { startOsuGoalsPoller } from '#lib/server/osu-goals/poller.js';
|
||||
import { registerPubgMapChannel } from '#lib/server/pubg-map/live.js';
|
||||
import { registerWorldtodoChannels } from '#lib/server/worldtodo/live.js';
|
||||
import { building } from '$app/env';
|
||||
import { auth } from '#lib/server/auth.js';
|
||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
||||
@@ -12,13 +13,14 @@ const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
|
||||
|
||||
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected)
|
||||
// the CS2 / PUBG / R6 match history poller and the osu! goals refresh. Also the WebSocket channels (served by
|
||||
// server.js on the same Bun server): the PUBG drop map rooms.
|
||||
// server.js on the same Bun server): the PUBG drop map rooms and the worldwide todo list.
|
||||
export const init: ServerInit = () => {
|
||||
if (!building) {
|
||||
startMusicPoller();
|
||||
startGamesPoller();
|
||||
startOsuGoalsPoller();
|
||||
registerPubgMapChannel();
|
||||
registerWorldtodoChannels();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
25
src/lib/assets/projects/worldwide-todo-list.svg
Normal file
25
src/lib/assets/projects/worldwide-todo-list.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Worldwide todo list">
|
||||
<defs>
|
||||
<linearGradient id="wt-sea" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#38bdf8"/>
|
||||
<stop offset="1" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wt-land" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#4ade80"/>
|
||||
<stop offset="1" stop-color="#16a34a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wt-check" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fbbf24"/>
|
||||
<stop offset="1" stop-color="#ea580c"/>
|
||||
</linearGradient>
|
||||
<clipPath id="wt-globe"><circle cx="28" cy="30" r="22"/></clipPath>
|
||||
</defs>
|
||||
<circle cx="28" cy="30" r="22" fill="url(#wt-sea)"/>
|
||||
<g clip-path="url(#wt-globe)" fill="url(#wt-land)">
|
||||
<path d="M10 18c5-4 11-5 15-3 3 2 1 6-2 7-3 1-3 5 0 7 2 2 1 6-3 6-4 0-7-3-9-7-2-3-3-7-1-10z"/>
|
||||
<path d="M33 11c5 0 10 2 13 6 1 2-1 4-4 4-3 0-5 2-4 5 1 3 5 3 7 5 2 3 0 8-4 10-3 2-6 0-6-3 0-3-3-4-4-7-1-3 2-5 1-8-1-3-4-4-3-7 1-3 2-5 4-5z"/>
|
||||
</g>
|
||||
<circle cx="28" cy="30" r="22" fill="none" stroke="#0f172a" stroke-opacity=".25" stroke-width="1.5"/>
|
||||
<rect x="34" y="34" width="26" height="26" rx="7" fill="url(#wt-check)" stroke="#fff" stroke-width="2.5"/>
|
||||
<path d="M40.5 47.5l4.5 4.5 9-10" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -21,6 +21,7 @@
|
||||
import Globe2 from 'lucide-svelte/icons/globe-2';
|
||||
import Languages from 'lucide-svelte/icons/languages';
|
||||
import ListChecks from 'lucide-svelte/icons/list-checks';
|
||||
import ListTodo from 'lucide-svelte/icons/list-todo';
|
||||
import Music2 from 'lucide-svelte/icons/music-2';
|
||||
import PawPrint from 'lucide-svelte/icons/paw-print';
|
||||
import Trophy from 'lucide-svelte/icons/trophy';
|
||||
@@ -40,6 +41,7 @@
|
||||
'cs prices': CandlestickChart,
|
||||
'house tracker': House,
|
||||
lists: ListChecks,
|
||||
'worldwide todo list': ListTodo,
|
||||
health: HeartPulse,
|
||||
books: BookOpen,
|
||||
stats: ChartLine,
|
||||
|
||||
@@ -6,6 +6,13 @@ export const projects = [
|
||||
href: 'https://esports.vash.software',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
name: 'worldwide todo list',
|
||||
subtitle:
|
||||
'real problems, planet to street, as a public todo list anyone can suggest changes to',
|
||||
href: '/projects/worldwide-todo',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'osu! map finder',
|
||||
subtitle: 'find osu! maps for Spotify songs/albums/playlists',
|
||||
|
||||
@@ -95,6 +95,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
||||
title: 'Pokémon locations',
|
||||
description: 'Every Pokémon location by region, with the Pokémon you can find there.'
|
||||
},
|
||||
'/projects/worldwide-todo': {
|
||||
title: 'The worldwide todo list',
|
||||
description:
|
||||
'A public todo list of real, sourced problems, from planet-scale to a single street. Anyone can suggest additions, edits and removals.'
|
||||
},
|
||||
'/projects/animaldex': {
|
||||
title: 'Animaldex',
|
||||
description: 'Find and add neighborhood animals on a map.'
|
||||
|
||||
@@ -30,4 +30,5 @@ export * from './stats';
|
||||
export * from './pubg-map';
|
||||
export * from './ai';
|
||||
export * from './movies';
|
||||
export * from './worldtodo';
|
||||
export * from './osu-goals';
|
||||
|
||||
@@ -27,3 +27,4 @@ export const dataholdersSchema = pgSchema('dataholders');
|
||||
export const statsSchema = pgSchema('stats');
|
||||
export const aiSchema = pgSchema('ai');
|
||||
export const moviesSchema = pgSchema('movies');
|
||||
export const worldtodoSchema = pgSchema('worldtodo');
|
||||
|
||||
124
src/lib/server/db/schema/worldtodo.ts
Normal file
124
src/lib/server/db/schema/worldtodo.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
check,
|
||||
customType,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
jsonb,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { worldtodoSchema } from './schemas';
|
||||
import { timestamptz } from './helpers';
|
||||
|
||||
// The Worldwide Todo List (/projects/worldwide-todo): a public list of real problems phrased
|
||||
// as todos. Anyone can *request* an add, edit or delete (change_requests); nothing changes
|
||||
// until the owner approves it in the queue. No submitter data is stored beyond an optional
|
||||
// display name: no IP, email or user agent (rate limiting is in memory, see
|
||||
// #lib/server/worldtodo/limit). Seeded by scripts/seed-worldtodo.ts from data/worldtodo-seed.json.
|
||||
|
||||
// Kept in sync with #lib/worldtodo/shared (a unit test checks it); the schema folder is copied
|
||||
// into the app image on its own for the scripts, so it must not import from outside it.
|
||||
const WORLDTODO_SCALES = ['planet', 'region', 'country', 'city', 'street'] as const;
|
||||
const WORLDTODO_STATUSES = ['open', 'in_progress', 'done'] as const;
|
||||
const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
|
||||
const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
|
||||
|
||||
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
|
||||
|
||||
export const worldtodoScale = worldtodoSchema.enum('scale', WORLDTODO_SCALES);
|
||||
export const worldtodoStatus = worldtodoSchema.enum('status', WORLDTODO_STATUSES);
|
||||
export const worldtodoRequestKind = worldtodoSchema.enum('req_kind', WORLDTODO_REQUEST_KINDS);
|
||||
export const worldtodoRequestStatus = worldtodoSchema.enum(
|
||||
'req_status',
|
||||
WORLDTODO_REQUEST_STATUSES
|
||||
);
|
||||
|
||||
export const worldtodoCategories = worldtodoSchema.table('categories', {
|
||||
slug: text('slug').primaryKey(),
|
||||
label: text('label').notNull(),
|
||||
/** Icon key in #lib/worldtodo/icons (a lucide icon). */
|
||||
icon: text('icon').notNull(),
|
||||
/** Hex accent for the icon tile only. */
|
||||
colour: text('colour').notNull()
|
||||
});
|
||||
|
||||
export const worldtodoTasks = worldtodoSchema.table(
|
||||
'tasks',
|
||||
{
|
||||
id: bigint('id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
slug: text('slug').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description').notNull(),
|
||||
scale: worldtodoScale('scale').notNull(),
|
||||
category: text('category')
|
||||
.notNull()
|
||||
.references(() => worldtodoCategories.slug),
|
||||
locationName: text('location_name').notNull(),
|
||||
/** ISO 3166-1 alpha-2; null for planet-scale or multinational tasks. */
|
||||
countryCode: text('country_code'),
|
||||
lat: doublePrecision('lat'),
|
||||
lon: doublePrecision('lon'),
|
||||
status: worldtodoStatus('status').notNull().default('open'),
|
||||
sourceUrls: text('source_urls').array().notNull(),
|
||||
/** Date of the main source as given ('2026-09', '2026-10-21'). */
|
||||
sourceDate: text('source_date'),
|
||||
confidence: text('confidence').notNull().default('high'),
|
||||
/** Owner-facing caveat, also shown subtly on the detail page. */
|
||||
verificationNote: text('verification_note'),
|
||||
/** When the facts were last checked. */
|
||||
asOf: date('as_of').notNull(),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow(),
|
||||
/** Soft delete (approved delete requests); hard purge is a manual owner action. */
|
||||
deletedAt: timestamptz('deleted_at'),
|
||||
search: tsvector('search').generatedAlwaysAs(
|
||||
sql`to_tsvector('simple', title || ' ' || description || ' ' || location_name)`
|
||||
)
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('tasks_slug_key').on(t.slug),
|
||||
index('tasks_search_idx').using('gin', t.search),
|
||||
index('tasks_scale_status_idx')
|
||||
.on(t.scale, t.status)
|
||||
.where(sql`deleted_at IS NULL`),
|
||||
check('tasks_title_check', sql`char_length(${t.title}) BETWEEN 5 AND 140`),
|
||||
check('tasks_description_check', sql`char_length(${t.description}) <= 2000`),
|
||||
check('tasks_lat_check', sql`${t.lat} BETWEEN -90 AND 90`),
|
||||
check('tasks_lon_check', sql`${t.lon} BETWEEN -180 AND 180`),
|
||||
check('tasks_sources_check', sql`cardinality(${t.sourceUrls}) >= 1`),
|
||||
check('tasks_confidence_check', sql`${t.confidence} IN ('high','medium')`)
|
||||
]
|
||||
);
|
||||
|
||||
export const worldtodoChangeRequests = worldtodoSchema.table(
|
||||
'change_requests',
|
||||
{
|
||||
id: bigint('id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
|
||||
kind: worldtodoRequestKind('kind').notNull(),
|
||||
/** Null for 'add'. */
|
||||
taskId: bigint('task_id', { mode: 'number' }).references(() => worldtodoTasks.id),
|
||||
/** add: full task fields; edit: only changed fields; delete: {}. Whitelisted keys only. */
|
||||
payload: jsonb('payload').$type<Record<string, unknown>>().notNull().default({}),
|
||||
/** What the owner actually applied (differs from payload after "edit & approve"). */
|
||||
applied: jsonb('applied').$type<Record<string, unknown>>(),
|
||||
/** The task's updated_at when the request was filed, to flag stale requests. */
|
||||
taskUpdatedAt: timestamptz('task_updated_at'),
|
||||
reason: text('reason').notNull(),
|
||||
/** Optional, the ONLY submitter info stored; shown nowhere except the owner queue. */
|
||||
displayName: text('display_name'),
|
||||
status: worldtodoRequestStatus('status').notNull().default('pending'),
|
||||
ownerNote: text('owner_note'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||
decidedAt: timestamptz('decided_at')
|
||||
},
|
||||
(t) => [
|
||||
index('change_requests_status_idx').on(t.status, t.createdAt),
|
||||
index('change_requests_task_idx').on(t.taskId, t.status),
|
||||
check('change_requests_reason_check', sql`char_length(${t.reason}) BETWEEN 10 AND 1000`),
|
||||
check('change_requests_display_name_check', sql`char_length(${t.displayName}) <= 40`),
|
||||
check('change_requests_task_check', sql`((${t.kind} = 'add') = (${t.taskId} IS NULL))`)
|
||||
]
|
||||
);
|
||||
@@ -32,7 +32,10 @@ export const ownerOnlyPrefixes = [
|
||||
'/trackers/ai/usage',
|
||||
'/lists/db',
|
||||
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
||||
'/api/spotify'
|
||||
'/api/spotify',
|
||||
// Worldwide todo list: the list is public, the review queue and its API are mine only.
|
||||
'/projects/worldwide-todo/queue',
|
||||
'/projects/worldwide-todo/api/queue'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
33
src/lib/server/worldtodo/decide.ts
Normal file
33
src/lib/server/worldtodo/decide.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { TaskFields } from '#lib/worldtodo/shared.js';
|
||||
import { publishQueue, publishTask } from './live';
|
||||
import * as store from './store';
|
||||
import { plain } from './validate';
|
||||
|
||||
// Owner decisions (queue actions, API, direct edits). Applied in one transaction in the
|
||||
// store, then published on the realtime channels.
|
||||
|
||||
export const ownerNote = (v: unknown) =>
|
||||
plain(typeof v === 'string' ? v : '', true).slice(0, 1000) || null;
|
||||
|
||||
async function publishApplied(applied: store.Applied) {
|
||||
const found = applied.type === 'task.deleted' ? null : await store.getTask(applied.slug);
|
||||
publishTask(applied.type, applied.slug, found?.task ?? null);
|
||||
publishQueue(await store.pendingTotal());
|
||||
}
|
||||
|
||||
export async function approveRequest(id: number, note: unknown, tweaked: TaskFields | null = null) {
|
||||
const applied = await store.approve(id, ownerNote(note), tweaked);
|
||||
await publishApplied(applied);
|
||||
return applied;
|
||||
}
|
||||
|
||||
export async function rejectRequest(id: number, note: unknown) {
|
||||
await store.reject(id, ownerNote(note));
|
||||
publishQueue(await store.pendingTotal());
|
||||
}
|
||||
|
||||
export async function editTaskDirectly(slug: string, fields: TaskFields, note: unknown) {
|
||||
const applied = await store.ownerEdit(slug, fields, ownerNote(note));
|
||||
await publishApplied(applied);
|
||||
return applied;
|
||||
}
|
||||
37
src/lib/server/worldtodo/limit.test.ts
Normal file
37
src/lib/server/worldtodo/limit.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PER_DAY, PER_HOUR, createLimiter } from './limit';
|
||||
|
||||
describe('worldtodo rate limiter', () => {
|
||||
it('allows 5 per hour, then answers with a Retry-After', () => {
|
||||
let t = Date.UTC(2026, 9, 26, 10, 0, 0);
|
||||
const l = createLimiter(() => t);
|
||||
for (let i = 0; i < PER_HOUR; i++) expect(l.hit('1.2.3.4')).toBeNull();
|
||||
const wait = l.hit('1.2.3.4');
|
||||
expect(wait).toBeGreaterThan(3500);
|
||||
expect(wait).toBeLessThanOrEqual(3600);
|
||||
expect(l.hit('5.6.7.8')).toBeNull(); // other clients unaffected
|
||||
t += 3_600_001;
|
||||
expect(l.hit('1.2.3.4')).toBeNull();
|
||||
});
|
||||
|
||||
it('caps at 20 per day', () => {
|
||||
let t = Date.UTC(2026, 9, 26, 0, 30, 0);
|
||||
const l = createLimiter(() => t);
|
||||
for (let i = 0; i < PER_DAY; i++) {
|
||||
expect(l.hit('9.9.9.9')).toBeNull();
|
||||
t += 13 * 60_000; // ~4.6 per hour, under the hourly cap
|
||||
}
|
||||
expect(l.hit('9.9.9.9')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('never keeps the raw IP and rotates the salt every UTC day', () => {
|
||||
let t = Date.UTC(2026, 9, 26, 23, 59, 0);
|
||||
const l = createLimiter(() => t);
|
||||
const k1 = l.keyOf('1.2.3.4');
|
||||
expect(k1).not.toContain('1.2.3.4');
|
||||
expect(l.keyOf('1.2.3.4')).toBe(k1);
|
||||
t += 120_000; // next UTC day
|
||||
expect(l.keyOf('1.2.3.4')).not.toBe(k1);
|
||||
expect(l.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
66
src/lib/server/worldtodo/limit.ts
Normal file
66
src/lib/server/worldtodo/limit.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
|
||||
// Rate limiting for public change requests: 5 per hour and 20 per day per client.
|
||||
// The client key is an HMAC of the IP address with a random salt that lives only in this
|
||||
// process's memory and rotates every UTC day, so a key can't be linked to an IP (or to
|
||||
// yesterday's key) and nothing is ever written to Postgres or logs. Counters are per process
|
||||
// and reset on restart, like the site's other limiters.
|
||||
|
||||
export const PER_HOUR = 5;
|
||||
export const PER_DAY = 20;
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 86_400_000;
|
||||
const MAX_KEYS = 50_000;
|
||||
|
||||
type State = { day: string; salt: Buffer; hits: Map<string, number[]> };
|
||||
|
||||
export function createLimiter(now: () => number = Date.now) {
|
||||
let state: State | null = null;
|
||||
const current = () => {
|
||||
const day = new Date(now()).toISOString().slice(0, 10);
|
||||
if (!state || state.day !== day) state = { day, salt: randomBytes(32), hits: new Map() };
|
||||
return state;
|
||||
};
|
||||
const keyOf = (ip: string) =>
|
||||
createHmac('sha256', current().salt).update(ip).digest('base64url').slice(0, 22);
|
||||
|
||||
return {
|
||||
/** The salted hash for an IP (exposed for tests; never stored). */
|
||||
keyOf,
|
||||
/**
|
||||
* Count one attempt. Returns null when allowed, or the seconds to wait (Retry-After).
|
||||
* Blocked attempts are not counted.
|
||||
*/
|
||||
hit(ip: string): number | null {
|
||||
const s = current();
|
||||
const key = keyOf(ip);
|
||||
const t = now();
|
||||
const list = (s.hits.get(key) ?? []).filter((x) => t - x < DAY);
|
||||
const lastHour = list.filter((x) => t - x < HOUR);
|
||||
if (lastHour.length >= PER_HOUR)
|
||||
return Math.max(1, Math.ceil((lastHour[0] + HOUR - t) / 1000));
|
||||
if (list.length >= PER_DAY) return Math.max(1, Math.ceil((list[0] + DAY - t) / 1000));
|
||||
list.push(t);
|
||||
if (!s.hits.has(key) && s.hits.size >= MAX_KEYS) s.hits.delete(s.hits.keys().next().value!);
|
||||
s.hits.set(key, list);
|
||||
return null;
|
||||
},
|
||||
/** Number of tracked keys (tests). */
|
||||
size: () => current().hits.size
|
||||
};
|
||||
}
|
||||
|
||||
export const limiter = createLimiter();
|
||||
|
||||
/** The client IP behind Cloudflare/Caddy, same order as server.js and the listening stream. */
|
||||
export function clientIp(request: Request, getClientAddress: () => string): string {
|
||||
const h = request.headers;
|
||||
const forwarded =
|
||||
h.get('cf-connecting-ip') ?? h.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '';
|
||||
if (forwarded) return forwarded;
|
||||
try {
|
||||
return getClientAddress();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
86
src/lib/server/worldtodo/live.ts
Normal file
86
src/lib/server/worldtodo/live.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { registerChannel, type Socket } from '#lib/server/ws/hub.js';
|
||||
import { auth } from '#lib/server/auth.js';
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import type { LiveEvent, PublicTask } from '#lib/worldtodo/shared.js';
|
||||
|
||||
// Realtime for the Worldwide Todo List on the site WebSocket hub:
|
||||
// - `worldtodo` (public, read-only): every approved change, as
|
||||
// { type: 'task.added' | 'task.updated' | 'task.deleted', slug, task | null, at }.
|
||||
// - `worldtodo-owner` (owner session only): { type: 'queue.changed', pending } when a request
|
||||
// is filed or decided, so the queue page and the header badge refresh without polling.
|
||||
// Clients send nothing; any message closes the socket. Pages fall back to polling (60 s)
|
||||
// when the socket can't connect (e.g. `vite dev`, which has no Bun.serve).
|
||||
|
||||
const MAX_PER_IP = 8;
|
||||
const publicSockets = new Set<Socket>();
|
||||
const ownerSockets = new Set<Socket>();
|
||||
const perIp = new Map<string, number>();
|
||||
|
||||
function track(set: Set<Socket>) {
|
||||
return {
|
||||
open(ws: Socket) {
|
||||
set.add(ws);
|
||||
},
|
||||
message(ws: Socket) {
|
||||
ws.close(1008, 'read only');
|
||||
},
|
||||
close(ws: Socket) {
|
||||
set.delete(ws);
|
||||
const n = (perIp.get(ws.data.ip) ?? 1) - 1;
|
||||
if (n <= 0) perIp.delete(ws.data.ip);
|
||||
else perIp.set(ws.data.ip, n);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function admit(ip: string): Response | null {
|
||||
const n = perIp.get(ip) ?? 0;
|
||||
if (n >= MAX_PER_IP) return new Response('Too Many Connections', { status: 429 });
|
||||
perIp.set(ip, n + 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
export function registerWorldtodoChannels() {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerChannel('worldtodo', {
|
||||
async authorize(_request, _url, ip) {
|
||||
return admit(ip) ?? {};
|
||||
},
|
||||
...track(publicSockets)
|
||||
});
|
||||
registerChannel('worldtodo-owner', {
|
||||
async authorize(request, _url, ip) {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!isOwner({ locals: { user: session?.user ?? null } as App.Locals }))
|
||||
return new Response('Not Found', { status: 404 });
|
||||
return admit(ip) ?? {};
|
||||
},
|
||||
...track(ownerSockets)
|
||||
});
|
||||
}
|
||||
|
||||
function send(set: Set<Socket>, msg: unknown) {
|
||||
const text = JSON.stringify(msg);
|
||||
for (const ws of set) {
|
||||
try {
|
||||
ws.send(text);
|
||||
} catch {
|
||||
/* closing socket */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function publishTask(type: LiveEvent['type'], slug: string, task: PublicTask | null) {
|
||||
send(publicSockets, {
|
||||
type,
|
||||
slug,
|
||||
task: type === 'task.deleted' ? null : task,
|
||||
at: new Date().toISOString()
|
||||
} satisfies LiveEvent);
|
||||
}
|
||||
|
||||
export function publishQueue(pending: number) {
|
||||
send(ownerSockets, { type: 'queue.changed', pending });
|
||||
}
|
||||
116
src/lib/server/worldtodo/requests.ts
Normal file
116
src/lib/server/worldtodo/requests.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { changedFields, type TaskFields } from '#lib/worldtodo/shared.js';
|
||||
import { limiter } from './limit';
|
||||
import { publishQueue } from './live';
|
||||
import * as store from './store';
|
||||
import { turnstileConfigured, verifyTurnstile } from './turnstile';
|
||||
import { looksLikeBot, parseRequest, type FieldErrors } from './validate';
|
||||
|
||||
// Filing a public change request (shared by the form action and the JSON API). Order:
|
||||
// bot checks → validation (no network, so a typo never burns a Turnstile token or a rate
|
||||
// slot) → rate limit (before Siteverify, so floods never reach Cloudflare) → global queue
|
||||
// cap → Turnstile → insert. Nothing about the submitter is stored except the optional name.
|
||||
|
||||
export const MAX_PENDING = 200;
|
||||
|
||||
export type SubmitResult =
|
||||
| { ok: true; id: number }
|
||||
| { ok: false; status: number; message: string; errors?: FieldErrors; retryAfter?: number };
|
||||
|
||||
export async function submitRequest(opts: {
|
||||
input: Record<string, unknown>;
|
||||
honeypot: unknown;
|
||||
startedAt: unknown;
|
||||
token: string;
|
||||
ip: string;
|
||||
now?: number;
|
||||
}): Promise<SubmitResult> {
|
||||
if (!turnstileConfigured())
|
||||
return { ok: false, status: 503, message: 'Requests are switched off right now.' };
|
||||
if (looksLikeBot(opts.honeypot, opts.startedAt, opts.now))
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'That was a bit quick. Wait a few seconds and send it again.'
|
||||
};
|
||||
// API edits may be partial, so validate them on top of the task as it is now.
|
||||
let base: Record<string, unknown> | undefined;
|
||||
if (opts.input.kind === 'edit' && typeof opts.input.task === 'string' && opts.input.task.trim()) {
|
||||
const current = await store.getTask(opts.input.task.trim());
|
||||
if (current) base = { ...store.currentFields(current.task) };
|
||||
}
|
||||
const parsed = parseRequest(opts.input, base);
|
||||
if (!parsed.ok)
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'Check the highlighted fields.',
|
||||
errors: parsed.errors
|
||||
};
|
||||
const req = parsed.value;
|
||||
|
||||
let taskId: number | null = null;
|
||||
let taskUpdatedAt: string | null = null;
|
||||
let payload: Record<string, unknown> = {};
|
||||
if (req.kind === 'add') {
|
||||
if (!(await store.categoryExists(req.fields!.category)))
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'Check the highlighted fields.',
|
||||
errors: { category: 'Pick a category' }
|
||||
};
|
||||
payload = { ...req.fields! };
|
||||
} else {
|
||||
const found = await store.getTask(req.taskSlug);
|
||||
if (!found) return { ok: false, status: 404, message: 'That task does not exist (anymore).' };
|
||||
taskId = found.task.id;
|
||||
taskUpdatedAt = found.task.updatedAt;
|
||||
if (req.kind === 'edit') {
|
||||
const change = changedFields(store.currentFields(found.task), req.fields as TaskFields);
|
||||
if (!Object.keys(change).length)
|
||||
return { ok: false, status: 400, message: 'Nothing changed: edit at least one field.' };
|
||||
if (change.category && !(await store.categoryExists(change.category)))
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'Check the highlighted fields.',
|
||||
errors: { category: 'Pick a category' }
|
||||
};
|
||||
payload = change as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
const wait = limiter.hit(opts.ip);
|
||||
if (wait !== null)
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
message: `You've sent a lot of requests. Try again in ${Math.ceil(wait / 60)} min.`,
|
||||
retryAfter: wait
|
||||
};
|
||||
if ((await store.pendingTotal()) >= MAX_PENDING)
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
message: 'The review queue is full right now. Please try again in a day or two.',
|
||||
retryAfter: 86_400
|
||||
};
|
||||
if (!(await verifyTurnstile(opts.token)))
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'The anti-bot check failed or expired. Try it again.',
|
||||
errors: { turnstile: 'Complete the check' }
|
||||
};
|
||||
|
||||
const row = await store.insertRequest({
|
||||
kind: req.kind,
|
||||
taskId,
|
||||
taskUpdatedAt,
|
||||
payload,
|
||||
reason: req.reason,
|
||||
displayName: req.displayName
|
||||
});
|
||||
publishQueue(await store.pendingTotal());
|
||||
return { ok: true, id: row.id };
|
||||
}
|
||||
555
src/lib/server/worldtodo/store.ts
Normal file
555
src/lib/server/worldtodo/store.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import type { TransactionSql } from 'postgres';
|
||||
// jsonb values are sent as JSON text with a ::jsonb cast: drizzle swaps the shared client's
|
||||
// json serializers for pass-through ones, so sql.json() would send a raw object.
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
import {
|
||||
EDITABLE_FIELDS,
|
||||
RESERVED_SLUGS,
|
||||
changedFields,
|
||||
type Category,
|
||||
type PublicTask,
|
||||
type RequestKind,
|
||||
type RequestStatus,
|
||||
type TaskFields
|
||||
} from '#lib/worldtodo/shared.js';
|
||||
|
||||
// Postgres access for the Worldwide Todo List (schema worldtodo). Hand-written SQL on the
|
||||
// shared postgres.js pool; every write that applies a request runs in one transaction.
|
||||
|
||||
const TASK_COLUMNS = sql`
|
||||
t.id, t.slug, t.title, t.description, t.scale::text as scale, t.category,
|
||||
t.location_name, t.country_code, t.lat, t.lon, t.status::text as status, t.source_urls,
|
||||
t.source_date, t.confidence, t.verification_note, t.as_of::text as as_of,
|
||||
t.created_at, t.updated_at, t.deleted_at`;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
// The shared client returns timestamptz as Postgres text ("2026-10-26 01:13:36.1+00") because
|
||||
// drizzle swaps in pass-through parsers, so normalise to ISO here.
|
||||
function toDate(v: unknown): Date | null {
|
||||
if (v instanceof Date) return v;
|
||||
if (!v) return null;
|
||||
const d = new Date(
|
||||
String(v)
|
||||
.replace(' ', 'T')
|
||||
.replace(/([+-]\d\d)$/, '$1:00')
|
||||
);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
const iso = (v: unknown) => toDate(v)?.toISOString() ?? '';
|
||||
|
||||
export function toTask(r: Row): PublicTask {
|
||||
return {
|
||||
id: Number(r.id),
|
||||
slug: String(r.slug),
|
||||
title: String(r.title),
|
||||
description: String(r.description),
|
||||
scale: r.scale as PublicTask['scale'],
|
||||
category: String(r.category),
|
||||
locationName: String(r.location_name),
|
||||
countryCode: (r.country_code as string | null) ?? null,
|
||||
lat: r.lat === null || r.lat === undefined ? null : Number(r.lat),
|
||||
lon: r.lon === null || r.lon === undefined ? null : Number(r.lon),
|
||||
status: r.status as PublicTask['status'],
|
||||
sourceUrls: (r.source_urls as string[]) ?? [],
|
||||
sourceDate: (r.source_date as string | null) ?? null,
|
||||
confidence: r.confidence === 'medium' ? 'medium' : 'high',
|
||||
verificationNote: (r.verification_note as string | null) ?? null,
|
||||
asOf: String(r.as_of),
|
||||
createdAt: iso(r.created_at),
|
||||
updatedAt: iso(r.updated_at),
|
||||
...(r.pending !== undefined ? { pending: Number(r.pending) } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCategories(): Promise<Category[]> {
|
||||
const rows = await sql`select slug, label, icon, colour from worldtodo.categories order by label`;
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
label: r.label,
|
||||
icon: r.icon,
|
||||
colour: r.colour
|
||||
}));
|
||||
}
|
||||
|
||||
export type Sort = 'updated' | 'scale' | 'title';
|
||||
export type ListFilter = {
|
||||
scale?: string;
|
||||
category?: string;
|
||||
/** 'todo' (open + in progress, the default), 'open', 'in_progress', 'done' or 'all'. */
|
||||
status?: string;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
function likeEscape(s: string) {
|
||||
return s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
}
|
||||
|
||||
function whereFor(f: ListFilter, opts: { skipScale?: boolean } = {}) {
|
||||
const parts = [sql`t.deleted_at is null`];
|
||||
if (f.scale && !opts.skipScale) parts.push(sql`t.scale = ${f.scale}::worldtodo.scale`);
|
||||
if (f.category) parts.push(sql`t.category = ${f.category}`);
|
||||
const status = f.status || 'todo';
|
||||
if (status === 'todo') parts.push(sql`t.status <> 'done'`);
|
||||
else if (status !== 'all') parts.push(sql`t.status = ${status}::worldtodo.status`);
|
||||
const q = (f.q ?? '').trim().slice(0, 100);
|
||||
if (q) {
|
||||
const like = `%${likeEscape(q)}%`;
|
||||
parts.push(
|
||||
sql`(t.search @@ websearch_to_tsquery('simple', ${q}) or t.title ilike ${like}
|
||||
or t.location_name ilike ${like} or t.description ilike ${like})`
|
||||
);
|
||||
}
|
||||
return parts.reduce((acc, p, i) => (i === 0 ? p : sql`${acc} and ${p}`));
|
||||
}
|
||||
|
||||
const SCALE_ORDER = sql`array_position(array['planet','region','country','city','street']::worldtodo.scale[], t.scale)`;
|
||||
|
||||
function orderFor(sort: Sort) {
|
||||
if (sort === 'title') return sql`lower(t.title) asc, t.id asc`;
|
||||
if (sort === 'scale') return sql`${SCALE_ORDER} asc, lower(t.title) asc, t.id asc`;
|
||||
return sql`t.updated_at desc, t.id desc`;
|
||||
}
|
||||
|
||||
function cursorKey(sort: Sort, r: Row): unknown[] {
|
||||
if (sort === 'title') return [String(r.title).toLowerCase(), Number(r.id)];
|
||||
if (sort === 'scale') return [Number(r.scale_pos), String(r.title).toLowerCase(), Number(r.id)];
|
||||
return [iso(r.updated_at), Number(r.id)];
|
||||
}
|
||||
|
||||
function afterCursor(sort: Sort, cursor: string | undefined) {
|
||||
if (!cursor) return null;
|
||||
try {
|
||||
const k = JSON.parse(Buffer.from(cursor, 'base64url').toString()) as unknown[];
|
||||
if (sort === 'title' && k.length === 2)
|
||||
return sql`(lower(t.title), t.id) > (${String(k[0])}, ${Number(k[1])})`;
|
||||
if (sort === 'scale' && k.length === 3)
|
||||
return sql`(${SCALE_ORDER}, lower(t.title), t.id) > (${Number(k[0])}, ${String(k[1])}, ${Number(k[2])})`;
|
||||
if (sort === 'updated' && k.length === 2)
|
||||
return sql`(t.updated_at, t.id) < (${String(k[0])}::timestamptz, ${Number(k[1])})`;
|
||||
} catch {
|
||||
/* bad cursor: start from the top */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Paged list (keyset cursor), excludes deleted tasks; each task carries its pending count. */
|
||||
export async function listTasks(
|
||||
f: ListFilter,
|
||||
sort: Sort = 'updated',
|
||||
limit = 50,
|
||||
cursor?: string
|
||||
): Promise<{ tasks: PublicTask[]; nextCursor: string | null }> {
|
||||
const after = afterCursor(sort, cursor);
|
||||
const where = after ? sql`${whereFor(f)} and ${after}` : whereFor(f);
|
||||
const rows = await sql`
|
||||
select ${TASK_COLUMNS}, ${SCALE_ORDER} as scale_pos,
|
||||
(select count(*) from worldtodo.change_requests r
|
||||
where r.task_id = t.id and r.status = 'pending')::int as pending
|
||||
from worldtodo.tasks t
|
||||
where ${where}
|
||||
order by ${orderFor(sort)}
|
||||
limit ${limit + 1}`;
|
||||
const more = rows.length > limit;
|
||||
const page = rows.slice(0, limit);
|
||||
return {
|
||||
tasks: page.map(toTask),
|
||||
nextCursor:
|
||||
more && page.length
|
||||
? Buffer.from(JSON.stringify(cursorKey(sort, page.at(-1)!))).toString('base64url')
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/** Counts for the hero (per status) and the scale strip (per scale, other filters applied). */
|
||||
export async function counts(f: ListFilter) {
|
||||
const [byStatus, byScale] = await Promise.all([
|
||||
sql`select status::text as status, count(*)::int as n from worldtodo.tasks
|
||||
where deleted_at is null group by status`,
|
||||
sql`select t.scale::text as scale, count(*)::int as n from worldtodo.tasks t
|
||||
where ${whereFor(f, { skipScale: true })} group by t.scale`
|
||||
]);
|
||||
const status: Record<string, number> = { open: 0, in_progress: 0, done: 0 };
|
||||
for (const r of byStatus) status[r.status] = r.n;
|
||||
const scale: Record<string, number> = {};
|
||||
for (const r of byScale) scale[r.scale] = r.n;
|
||||
return { status, scale };
|
||||
}
|
||||
|
||||
export async function getTask(slug: string, includeDeleted = false) {
|
||||
const [row] = await sql`
|
||||
select ${TASK_COLUMNS},
|
||||
(select count(*) from worldtodo.change_requests r
|
||||
where r.task_id = t.id and r.status = 'pending')::int as pending
|
||||
from worldtodo.tasks t
|
||||
where t.slug = ${slug} ${includeDeleted ? sql`` : sql`and t.deleted_at is null`}`;
|
||||
if (!row) return null;
|
||||
return { task: toTask(row), deletedAt: row.deleted_at ? iso(row.deleted_at) : null };
|
||||
}
|
||||
|
||||
/** Summary of a request that is safe to show publicly: never the reason or display name. */
|
||||
export function publicSummary(kind: RequestKind, payload: Record<string, unknown> | null) {
|
||||
if (kind === 'delete') return 'Request to remove this task';
|
||||
if (kind === 'add') return 'New task';
|
||||
const keys = Object.keys(payload ?? {}).filter((k) =>
|
||||
(EDITABLE_FIELDS as readonly string[]).includes(k)
|
||||
);
|
||||
const names = keys.map((k) => k.replace('_urls', 's').replace('_', ' '));
|
||||
return names.length ? `Changes ${names.join(', ')}` : 'Edit';
|
||||
}
|
||||
|
||||
export async function taskActivity(taskId: number) {
|
||||
const rows = await sql`
|
||||
select id, kind::text as kind, status::text as status, payload, applied, owner_note,
|
||||
created_at, decided_at
|
||||
from worldtodo.change_requests
|
||||
where task_id = ${taskId} and status in ('approved', 'pending')
|
||||
order by coalesce(decided_at, created_at) desc
|
||||
limit 100`;
|
||||
const history = rows
|
||||
.filter((r) => r.status === 'approved')
|
||||
.map((r) => ({
|
||||
id: Number(r.id),
|
||||
kind: r.kind as RequestKind,
|
||||
summary: publicSummary(r.kind, (r.applied ?? r.payload) as Record<string, unknown>),
|
||||
ownerNote: (r.owner_note as string | null) ?? null,
|
||||
at: iso(r.decided_at ?? r.created_at)
|
||||
}));
|
||||
const pending = rows
|
||||
.filter((r) => r.status === 'pending')
|
||||
.map((r) => ({
|
||||
id: Number(r.id),
|
||||
kind: r.kind as RequestKind,
|
||||
summary: publicSummary(r.kind, r.payload as Record<string, unknown>),
|
||||
at: iso(r.created_at)
|
||||
}));
|
||||
return { history, pending };
|
||||
}
|
||||
|
||||
/** The add request that created a task (if any), for the "added" line in its history. */
|
||||
export async function pendingTotal(): Promise<number> {
|
||||
const [r] =
|
||||
await sql`select count(*)::int as n from worldtodo.change_requests where status = 'pending'`;
|
||||
return r.n;
|
||||
}
|
||||
|
||||
export async function categoryExists(slug: string) {
|
||||
const [r] = await sql`select 1 from worldtodo.categories where slug = ${slug}`;
|
||||
return !!r;
|
||||
}
|
||||
|
||||
export async function insertRequest(input: {
|
||||
kind: RequestKind;
|
||||
taskId: number | null;
|
||||
taskUpdatedAt: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
reason: string;
|
||||
displayName: string | null;
|
||||
}) {
|
||||
const [r] = await sql`
|
||||
insert into worldtodo.change_requests
|
||||
(kind, task_id, task_updated_at, payload, reason, display_name)
|
||||
values (${input.kind}::worldtodo.req_kind, ${input.taskId}, ${input.taskUpdatedAt},
|
||||
${JSON.stringify(input.payload)}::jsonb, ${input.reason}, ${input.displayName})
|
||||
returning id, created_at`;
|
||||
return { id: Number(r.id), createdAt: iso(r.created_at) };
|
||||
}
|
||||
|
||||
export async function requestStatus(id: number) {
|
||||
const [r] = await sql`
|
||||
select r.id, r.kind::text as kind, r.status::text as status, r.created_at, r.decided_at,
|
||||
r.owner_note, t.slug, t.title, t.deleted_at
|
||||
from worldtodo.change_requests r
|
||||
left join worldtodo.tasks t on t.id = coalesce(r.task_id, (r.applied->>'_task_id')::bigint)
|
||||
where r.id = ${id}`;
|
||||
if (!r) return null;
|
||||
return {
|
||||
id: Number(r.id),
|
||||
kind: r.kind as RequestKind,
|
||||
status: r.status as RequestStatus,
|
||||
createdAt: iso(r.created_at),
|
||||
decidedAt: r.decided_at ? iso(r.decided_at) : null,
|
||||
ownerNote: r.status === 'pending' ? null : ((r.owner_note as string | null) ?? null),
|
||||
// Only link a task the public can already see (an approved add, or the edited task).
|
||||
task:
|
||||
r.slug && !r.deleted_at && (r.kind !== 'add' || r.status === 'approved')
|
||||
? { slug: String(r.slug), title: String(r.title) }
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
export type QueueItem = {
|
||||
id: number;
|
||||
kind: RequestKind;
|
||||
status: RequestStatus;
|
||||
payload: Record<string, unknown>;
|
||||
applied: Record<string, unknown> | null;
|
||||
reason: string;
|
||||
displayName: string | null;
|
||||
ownerNote: string | null;
|
||||
createdAt: string;
|
||||
decidedAt: string | null;
|
||||
task: PublicTask | null;
|
||||
taskDeleted: boolean;
|
||||
stale: boolean;
|
||||
similar: { slug: string; title: string; similarity: number }[];
|
||||
};
|
||||
|
||||
async function similarTitles(title: string, excludeId: number | null) {
|
||||
if (!title) return [];
|
||||
try {
|
||||
const rows = await sql`
|
||||
select slug, title, public.similarity(title, ${title}) as s
|
||||
from worldtodo.tasks
|
||||
where deleted_at is null and public.similarity(title, ${title}) > 0.4
|
||||
${excludeId ? sql`and id <> ${excludeId}` : sql``}
|
||||
order by s desc limit 3`;
|
||||
return rows.map((r) => ({
|
||||
slug: String(r.slug),
|
||||
title: String(r.title),
|
||||
similarity: Number(r.s)
|
||||
}));
|
||||
} catch {
|
||||
return []; // pg_trgm is optional
|
||||
}
|
||||
}
|
||||
|
||||
export async function queue(status: RequestStatus, limit = 100): Promise<QueueItem[]> {
|
||||
const order = status === 'pending' ? sql`r.created_at asc` : sql`r.decided_at desc nulls last`;
|
||||
// Request columns are aliased: TASK_COLUMNS brings its own id, status and created_at.
|
||||
const rows = await sql`
|
||||
select r.id as req_id, r.kind::text as kind, r.status::text as req_status, r.payload,
|
||||
r.applied, r.reason, r.display_name, r.owner_note, r.created_at as req_created_at,
|
||||
r.decided_at, r.task_updated_at, ${TASK_COLUMNS}, t.id as t_id
|
||||
from worldtodo.change_requests r
|
||||
left join worldtodo.tasks t on t.id = coalesce(r.task_id, (r.applied->>'_task_id')::bigint)
|
||||
where r.status = ${status}::worldtodo.req_status
|
||||
order by ${order}
|
||||
limit ${limit}`;
|
||||
return Promise.all(
|
||||
rows.map(async (r) => {
|
||||
const task = r.t_id ? toTask({ ...r, id: r.t_id }) : null;
|
||||
const deleted = !!r.deleted_at;
|
||||
const stale =
|
||||
r.kind !== 'add' &&
|
||||
(deleted ||
|
||||
!!(
|
||||
r.task_updated_at &&
|
||||
r.updated_at &&
|
||||
(toDate(r.updated_at)?.getTime() ?? 0) > (toDate(r.task_updated_at)?.getTime() ?? 0) + 1
|
||||
));
|
||||
const payload = (r.payload ?? {}) as Record<string, unknown>;
|
||||
const title = String(payload.title ?? '');
|
||||
return {
|
||||
id: Number(r.req_id),
|
||||
kind: r.kind as RequestKind,
|
||||
status: r.req_status as RequestStatus,
|
||||
payload,
|
||||
applied: (r.applied as Record<string, unknown> | null) ?? null,
|
||||
reason: String(r.reason),
|
||||
displayName: (r.display_name as string | null) ?? null,
|
||||
ownerNote: (r.owner_note as string | null) ?? null,
|
||||
createdAt: iso(r.req_created_at),
|
||||
decidedAt: r.decided_at ? iso(r.decided_at) : null,
|
||||
task,
|
||||
taskDeleted: deleted,
|
||||
stale: status === 'pending' && stale,
|
||||
similar:
|
||||
status === 'pending' && title && r.kind !== 'delete'
|
||||
? await similarTitles(title, task?.id ?? null)
|
||||
: []
|
||||
} satisfies QueueItem;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export class DecisionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function slugBase(title: string) {
|
||||
return (
|
||||
title
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 70)
|
||||
.replace(/-+$/g, '') || 'task'
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
type Tx = TransactionSql<{}>;
|
||||
|
||||
async function uniqueTaskSlug(tx: Tx, title: string) {
|
||||
const base = slugBase(title);
|
||||
let slug = RESERVED_SLUGS.has(base) ? `${base}-2` : base;
|
||||
let n = RESERVED_SLUGS.has(base) ? 3 : 2;
|
||||
while ((await tx`select 1 from worldtodo.tasks where slug = ${slug}`).length)
|
||||
slug = `${base}-${n++}`;
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** Full field set for an add (payload) with defaults. */
|
||||
function addFields(p: Record<string, unknown>): TaskFields {
|
||||
return {
|
||||
title: String(p.title ?? ''),
|
||||
description: String(p.description ?? ''),
|
||||
scale: p.scale as TaskFields['scale'],
|
||||
category: String(p.category ?? ''),
|
||||
location_name: String(p.location_name ?? ''),
|
||||
country_code: (p.country_code as string | null) ?? null,
|
||||
lat: (p.lat as number | null) ?? null,
|
||||
lon: (p.lon as number | null) ?? null,
|
||||
status: (p.status as TaskFields['status']) ?? 'open',
|
||||
source_urls: (p.source_urls as string[]) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
export type Applied = { type: 'task.added' | 'task.updated' | 'task.deleted'; slug: string };
|
||||
|
||||
/**
|
||||
* Approve a pending request: apply the add, edit or delete, set updated_at, mark the request
|
||||
* approved with the optional note, all in one transaction. `tweaked` (edit & approve) replaces
|
||||
* the payload: for an add the full field set, for an edit the proposed field set (diffed here).
|
||||
*/
|
||||
export async function approve(
|
||||
id: number,
|
||||
note: string | null,
|
||||
tweaked: TaskFields | null
|
||||
): Promise<Applied> {
|
||||
return sql.begin(async (tx) => {
|
||||
const [req] = await tx`
|
||||
select id, kind::text as kind, status::text as status, task_id, payload
|
||||
from worldtodo.change_requests where id = ${id} for update`;
|
||||
if (!req) throw new DecisionError('Request not found', 404);
|
||||
if (req.status !== 'pending') throw new DecisionError('This request was already decided');
|
||||
const payload = (req.payload ?? {}) as Record<string, unknown>;
|
||||
if (req.kind === 'add') {
|
||||
const fields = tweaked ?? addFields(payload);
|
||||
const [cat] = await tx`select 1 from worldtodo.categories where slug = ${fields.category}`;
|
||||
if (!cat) throw new DecisionError('Unknown category');
|
||||
const slug = await uniqueTaskSlug(tx, fields.title);
|
||||
const [task] = await tx`
|
||||
insert into worldtodo.tasks (slug, title, description, scale, category, location_name,
|
||||
country_code, lat, lon, status, source_urls, as_of, confidence)
|
||||
values (${slug}, ${fields.title}, ${fields.description}, ${fields.scale}::worldtodo.scale,
|
||||
${fields.category}, ${fields.location_name}, ${fields.country_code}, ${fields.lat},
|
||||
${fields.lon}, ${fields.status}::worldtodo.status, ${fields.source_urls},
|
||||
${today()}, 'medium')
|
||||
returning id`;
|
||||
await tx`
|
||||
update worldtodo.change_requests
|
||||
set status = 'approved', decided_at = now(), owner_note = ${note},
|
||||
applied = ${JSON.stringify({ ...fields, _task_id: Number(task.id) })}::jsonb
|
||||
where id = ${id}`;
|
||||
return { type: 'task.added', slug } satisfies Applied;
|
||||
}
|
||||
const [row] = await tx`
|
||||
select ${TASK_COLUMNS} from worldtodo.tasks t where t.id = ${req.task_id} for update`;
|
||||
if (!row) throw new DecisionError('The task no longer exists', 409);
|
||||
const current = toTask(row);
|
||||
if (req.kind === 'delete') {
|
||||
if (!row.deleted_at)
|
||||
await tx`update worldtodo.tasks set deleted_at = now(), updated_at = now() where id = ${current.id}`;
|
||||
await tx`
|
||||
update worldtodo.change_requests
|
||||
set status = 'approved', decided_at = now(), owner_note = ${note}, applied = '{}'::jsonb
|
||||
where id = ${id}`;
|
||||
return { type: 'task.deleted', slug: current.slug } satisfies Applied;
|
||||
}
|
||||
// edit
|
||||
if (row.deleted_at) throw new DecisionError('The task was removed; reject this edit', 409);
|
||||
const proposed = tweaked ?? { ...currentFields(current), ...pick(payload) };
|
||||
const change = changedFields(currentFields(current), proposed);
|
||||
if (!Object.keys(change).length) throw new DecisionError('Nothing would change');
|
||||
if (change.category) {
|
||||
const [cat] = await tx`select 1 from worldtodo.categories where slug = ${change.category}`;
|
||||
if (!cat) throw new DecisionError('Unknown category');
|
||||
}
|
||||
const next = { ...currentFields(current), ...change };
|
||||
await tx`
|
||||
update worldtodo.tasks set
|
||||
title = ${next.title}, description = ${next.description},
|
||||
scale = ${next.scale}::worldtodo.scale, category = ${next.category},
|
||||
location_name = ${next.location_name}, country_code = ${next.country_code},
|
||||
lat = ${next.lat}, lon = ${next.lon}, status = ${next.status}::worldtodo.status,
|
||||
source_urls = ${next.source_urls}, as_of = ${today()}, updated_at = now()
|
||||
where id = ${current.id}`;
|
||||
await tx`
|
||||
update worldtodo.change_requests
|
||||
set status = 'approved', decided_at = now(), owner_note = ${note},
|
||||
applied = ${JSON.stringify(change)}::jsonb
|
||||
where id = ${id}`;
|
||||
return { type: 'task.updated', slug: current.slug } satisfies Applied;
|
||||
}) as Promise<Applied>;
|
||||
}
|
||||
|
||||
export async function reject(id: number, note: string | null) {
|
||||
const rows = await sql`
|
||||
update worldtodo.change_requests
|
||||
set status = 'rejected', decided_at = now(), owner_note = ${note}
|
||||
where id = ${id} and status = 'pending'
|
||||
returning id`;
|
||||
if (!rows.length) {
|
||||
const [r] =
|
||||
await sql`select status::text as status from worldtodo.change_requests where id = ${id}`;
|
||||
throw new DecisionError(
|
||||
r ? 'This request was already decided' : 'Request not found',
|
||||
r ? 400 : 404
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Direct owner edit: applied at once and logged as an auto-approved request. */
|
||||
export async function ownerEdit(slug: string, proposed: TaskFields, note: string | null) {
|
||||
const found = await getTask(slug);
|
||||
if (!found) throw new DecisionError('Task not found', 404);
|
||||
const change = changedFields(currentFields(found.task), proposed);
|
||||
if (!Object.keys(change).length) throw new DecisionError('Nothing would change');
|
||||
const req = await insertRequest({
|
||||
kind: 'edit',
|
||||
taskId: found.task.id,
|
||||
taskUpdatedAt: found.task.updatedAt,
|
||||
payload: change as Record<string, unknown>,
|
||||
reason: 'Direct edit by the owner.',
|
||||
displayName: null
|
||||
});
|
||||
return approve(req.id, note ?? 'Direct owner edit', null);
|
||||
}
|
||||
|
||||
export function currentFields(t: PublicTask): TaskFields {
|
||||
return {
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
scale: t.scale,
|
||||
category: t.category,
|
||||
location_name: t.locationName,
|
||||
country_code: t.countryCode,
|
||||
lat: t.lat,
|
||||
lon: t.lon,
|
||||
status: t.status,
|
||||
source_urls: t.sourceUrls
|
||||
};
|
||||
}
|
||||
|
||||
/** Whitelist payload keys to the editable fields. */
|
||||
export function pick(p: Record<string, unknown>): Partial<TaskFields> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of EDITABLE_FIELDS) if (k in p) out[k] = p[k];
|
||||
return out as Partial<TaskFields>;
|
||||
}
|
||||
|
||||
export async function sitemapSlugs() {
|
||||
const rows =
|
||||
await sql`select slug, updated_at from worldtodo.tasks where deleted_at is null order by id`;
|
||||
return rows.map((r) => ({ slug: String(r.slug), updatedAt: iso(r.updated_at) }));
|
||||
}
|
||||
32
src/lib/server/worldtodo/turnstile.ts
Normal file
32
src/lib/server/worldtodo/turnstile.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TURNSTILE_SECRET_KEY } from '$app/env/private';
|
||||
import { PUBLIC_TURNSTILE_SITE_KEY } from '$app/env/public';
|
||||
|
||||
// Cloudflare Turnstile on the request form, checked server-side through Siteverify.
|
||||
// Widget "stanrunge.dev worldwide todo list" (managed mode, domain stanrunge.dev).
|
||||
// Without both keys, request submission is switched off and the form says so.
|
||||
|
||||
const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
export const turnstileSiteKey = () => PUBLIC_TURNSTILE_SITE_KEY || '';
|
||||
export const turnstileConfigured = () => !!(TURNSTILE_SECRET_KEY && PUBLIC_TURNSTILE_SITE_KEY);
|
||||
|
||||
export async function verifyTurnstile(
|
||||
token: string,
|
||||
fetcher: typeof fetch = fetch
|
||||
): Promise<boolean> {
|
||||
if (!turnstileConfigured() || !token || token.length > 2048) return false;
|
||||
try {
|
||||
const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token });
|
||||
const res = await fetcher(SITEVERIFY, {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: AbortSignal.timeout(8000)
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data.success === true;
|
||||
} catch (e) {
|
||||
console.error('[worldtodo] turnstile siteverify failed', (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
126
src/lib/server/worldtodo/validate.test.ts
Normal file
126
src/lib/server/worldtodo/validate.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { looksLikeBot, normaliseUrl, parseRequest, plain } from './validate';
|
||||
|
||||
const add = {
|
||||
kind: 'add',
|
||||
title: 'Fix the <b>broken</b> streetlights on Main Street',
|
||||
description: 'Half the lights have been out since August.\n\n<script>x</script>Please fix.',
|
||||
scale: 'street',
|
||||
category: 'safety',
|
||||
location_name: 'Main Street, Gouda',
|
||||
country_code: 'nl',
|
||||
lat: '52.01',
|
||||
lon: '4.71',
|
||||
status: 'open',
|
||||
source_urls: ['https://example.org/news', '', 'https://example.org/news'],
|
||||
reason: 'I walk here every evening and it is dark.',
|
||||
display_name: ''
|
||||
};
|
||||
|
||||
describe('worldtodo request validation', () => {
|
||||
it('accepts a valid add, strips HTML and normalises', () => {
|
||||
const r = parseRequest(add);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.fields?.title).toBe('Fix the broken streetlights on Main Street');
|
||||
expect(r.value.fields?.description).not.toContain('<');
|
||||
expect(r.value.fields?.country_code).toBe('NL');
|
||||
expect(r.value.fields?.lat).toBe(52.01);
|
||||
expect(r.value.fields?.source_urls).toEqual(['https://example.org/news']);
|
||||
expect(r.value.displayName).toBeNull();
|
||||
});
|
||||
|
||||
it('requires sources for add and edit, and a reason of 10+ chars', () => {
|
||||
const r = parseRequest({ ...add, source_urls: [], reason: 'short' });
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) return;
|
||||
expect(r.errors.source_urls).toBeTruthy();
|
||||
expect(r.errors.reason).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects non-http URLs, too many URLs and overlong URLs', () => {
|
||||
expect(normaliseUrl('javascript:alert(1)')).toBeNull();
|
||||
expect(normaliseUrl('ftp://example.org/x')).toBeNull();
|
||||
expect(normaliseUrl(`https://example.org/${'a'.repeat(500)}`)).toBeNull();
|
||||
expect(normaliseUrl('https://example.org/ok')).toBe('https://example.org/ok');
|
||||
const many = Array.from({ length: 6 }, (_, i) => `https://e${i}.org/`);
|
||||
const r = parseRequest({ ...add, source_urls: many });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('needs both coordinates or neither, in range', () => {
|
||||
expect(parseRequest({ ...add, lon: '' }).ok).toBe(false);
|
||||
expect(parseRequest({ ...add, lat: '91' }).ok).toBe(false);
|
||||
expect(parseRequest({ ...add, lat: '', lon: '' }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('delete needs only a task and a reason', () => {
|
||||
const r = parseRequest({
|
||||
kind: 'delete',
|
||||
task: 'some-task',
|
||||
reason: 'It was fixed last week.'
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value.fields).toBeNull();
|
||||
expect(parseRequest({ kind: 'delete', reason: 'It was fixed last week.' }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the display name at 40 characters', () => {
|
||||
expect(parseRequest({ ...add, display_name: 'x'.repeat(41) }).ok).toBe(false);
|
||||
const r = parseRequest({ ...add, display_name: ' <i>Anna</i> ' });
|
||||
expect(r.ok && r.value.displayName).toBe('Anna');
|
||||
});
|
||||
|
||||
it('flags the honeypot and too-fast submissions', () => {
|
||||
const now = 1_000_000;
|
||||
expect(looksLikeBot('', now - 10_000, now)).toBe(false);
|
||||
expect(looksLikeBot('http://spam', now - 10_000, now)).toBe(true);
|
||||
expect(looksLikeBot('', now - 1000, now)).toBe(true);
|
||||
expect(looksLikeBot('', 'garbage', now)).toBe(true);
|
||||
});
|
||||
|
||||
it('plain() keeps paragraphs but drops control characters', () => {
|
||||
expect(plain('a\u0000b\r\n\r\n\r\nc', true)).toBe('a b\n\nc');
|
||||
expect(plain(' one\ntwo ')).toBe('one two');
|
||||
});
|
||||
|
||||
it('add without the optional keys defaults them (JSON API)', () => {
|
||||
const { country_code, lat, lon, status, display_name, ...minimal } = add;
|
||||
void [country_code, lat, lon, status, display_name];
|
||||
const r = parseRequest(minimal);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.fields).toMatchObject({
|
||||
country_code: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
status: 'open'
|
||||
});
|
||||
});
|
||||
|
||||
it('a partial edit is validated on top of the current task', () => {
|
||||
const base = {
|
||||
title: 'Fix the broken streetlights on Main Street',
|
||||
description: 'Out since August.',
|
||||
scale: 'street',
|
||||
category: 'safety',
|
||||
location_name: 'Main Street, Gouda',
|
||||
country_code: 'NL',
|
||||
lat: 52.01,
|
||||
lon: 4.71,
|
||||
status: 'open',
|
||||
source_urls: ['https://example.org/news']
|
||||
};
|
||||
const r = parseRequest(
|
||||
{
|
||||
kind: 'edit',
|
||||
task: 'main-street',
|
||||
status: 'in_progress',
|
||||
reason: 'Works started on Monday.'
|
||||
},
|
||||
base
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value.fields).toMatchObject({ ...base, status: 'in_progress' });
|
||||
});
|
||||
});
|
||||
247
src/lib/server/worldtodo/validate.ts
Normal file
247
src/lib/server/worldtodo/validate.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
WORLDTODO_REQUEST_KINDS,
|
||||
WORLDTODO_SCALES,
|
||||
WORLDTODO_STATUSES,
|
||||
type RequestKind,
|
||||
type TaskFields
|
||||
} from '#lib/worldtodo/shared.js';
|
||||
|
||||
// Validation for public change requests (form action and JSON API share it). Everything is
|
||||
// plain text: HTML tags and control characters are stripped, Markdown is never rendered.
|
||||
// Source URLs must be http(s) and are stored as given (never fetched on submit).
|
||||
|
||||
export const MAX_URLS = 5;
|
||||
export const MAX_URL_LENGTH = 500;
|
||||
/** Minimum time between opening the form and submitting it (cheap bot filter). */
|
||||
export const MIN_FILL_MS = 3000;
|
||||
|
||||
/** Plain text: no tags, no control characters, trimmed. `multiline` keeps line breaks. */
|
||||
export function plain(value: unknown, multiline = false): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
let s = value.replace(/<[^>]*>?/g, '').replace(/[<>]/g, '');
|
||||
s = s.replace(/\r\n?/g, '\n');
|
||||
// eslint-disable-next-line no-control-regex
|
||||
s = s.replace(multiline ? /[\u0000-\u0009\u000b-\u001f\u007f]/g : /[\u0000-\u001f\u007f]/g, ' ');
|
||||
s = multiline
|
||||
? s
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/[ \t]+/g, ' ').trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
: s.replace(/\s+/g, ' ');
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
export function normaliseUrl(raw: string): string | null {
|
||||
const s = raw.trim();
|
||||
if (!s || s.length > MAX_URL_LENGTH) return null;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||
if (!u.hostname.includes('.')) return null;
|
||||
return s;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const optionalNumber = (min: number, max: number, label: string) =>
|
||||
z.union([z.string(), z.number(), z.null(), z.undefined()]).transform((v, ctx) => {
|
||||
if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) return null;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n) || n < min || n > max) {
|
||||
ctx.addIssue({ code: 'custom', message: `${label} must be between ${min} and ${max}` });
|
||||
return z.NEVER;
|
||||
}
|
||||
return Math.round(n * 1e6) / 1e6;
|
||||
});
|
||||
|
||||
export const fieldsSchema = z
|
||||
.object({
|
||||
title: z
|
||||
.string({ message: 'Add a title' })
|
||||
.transform((v) => plain(v))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(5, 'Title needs at least 5 characters')
|
||||
.max(140, 'Title can be at most 140 characters')
|
||||
),
|
||||
description: z
|
||||
.string({ message: 'Add a description' })
|
||||
.transform((v) => plain(v, true))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(10, 'Description needs at least 10 characters')
|
||||
.max(2000, 'Description can be at most 2000 characters')
|
||||
),
|
||||
scale: z.enum(WORLDTODO_SCALES, { message: 'Pick a scale' }),
|
||||
category: z
|
||||
.string({ message: 'Pick a category' })
|
||||
.trim()
|
||||
.regex(/^[a-z][a-z0-9-]{1,30}$/, 'Pick a category'),
|
||||
location_name: z
|
||||
.string({ message: 'Add a location' })
|
||||
.transform((v) => plain(v))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(2, 'Location needs at least 2 characters')
|
||||
.max(120, 'Location can be at most 120 characters')
|
||||
),
|
||||
country_code: z.union([z.string(), z.null(), z.undefined()]).transform((v, ctx) => {
|
||||
const s = (v ?? '').trim().toUpperCase();
|
||||
if (!s) return null;
|
||||
if (!/^[A-Z]{2}$/.test(s)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Country code is two letters, e.g. NL' });
|
||||
return z.NEVER;
|
||||
}
|
||||
return s;
|
||||
}),
|
||||
lat: optionalNumber(-90, 90, 'Latitude'),
|
||||
lon: optionalNumber(-180, 180, 'Longitude'),
|
||||
status: z.enum(WORLDTODO_STATUSES, { message: 'Pick a status' }),
|
||||
source_urls: z.array(z.string()).transform((list, ctx) => {
|
||||
const filled = list.map((s) => s.trim()).filter(Boolean);
|
||||
if (filled.length > MAX_URLS) {
|
||||
ctx.addIssue({ code: 'custom', message: `At most ${MAX_URLS} source links` });
|
||||
return z.NEVER;
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const raw of filled) {
|
||||
const url = normaliseUrl(raw);
|
||||
if (!url) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Source links must be http(s) URLs of at most ${MAX_URL_LENGTH} characters`
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
if (!out.includes(url)) out.push(url);
|
||||
}
|
||||
if (!out.length) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Add at least one source link' });
|
||||
return z.NEVER;
|
||||
}
|
||||
return out;
|
||||
})
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if ((v.lat === null) !== (v.lon === null))
|
||||
ctx.addIssue({ code: 'custom', path: ['lat'], message: 'Give both latitude and longitude' });
|
||||
});
|
||||
|
||||
const metaSchema = z.object({
|
||||
kind: z.enum(WORLDTODO_REQUEST_KINDS, { message: 'Unknown request type' }),
|
||||
task: z.string().trim().max(120).optional().default(''),
|
||||
reason: z
|
||||
.string()
|
||||
.transform((v) => plain(v, true))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(10, 'Reason needs at least 10 characters')
|
||||
.max(1000, 'Reason can be at most 1000 characters')
|
||||
),
|
||||
display_name: z
|
||||
.union([z.string(), z.null(), z.undefined()])
|
||||
.transform((v) => plain(v ?? '').slice(0, 200))
|
||||
.pipe(z.string().max(40, 'Display name can be at most 40 characters'))
|
||||
.transform((v) => v || null)
|
||||
});
|
||||
|
||||
export type ParsedRequest = {
|
||||
kind: RequestKind;
|
||||
taskSlug: string;
|
||||
fields: TaskFields | null;
|
||||
reason: string;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
export type FieldErrors = Record<string, string>;
|
||||
|
||||
/** Raw input (from FormData or JSON) → a validated request, or field errors. */
|
||||
export function parseRequest(
|
||||
input: Record<string, unknown>,
|
||||
base?: Record<string, unknown>
|
||||
): { ok: true; value: ParsedRequest } | { ok: false; errors: FieldErrors } {
|
||||
const errors: FieldErrors = {};
|
||||
// Optional keys are defaulted here: zod 4 treats a missing key in front of a transform as
|
||||
// required.
|
||||
const meta = metaSchema.safeParse({
|
||||
...input,
|
||||
task: input.task ?? '',
|
||||
display_name: input.display_name ?? ''
|
||||
});
|
||||
if (!meta.success)
|
||||
for (const issue of meta.error.issues)
|
||||
errors[String(issue.path[0] ?? 'form')] ??= issue.message;
|
||||
const kind = meta.success ? meta.data.kind : (input.kind as RequestKind);
|
||||
if ((kind === 'edit' || kind === 'delete') && !(meta.success && meta.data.task))
|
||||
errors.task ??= 'Which task is this about?';
|
||||
let fields: TaskFields | null = null;
|
||||
if (kind === 'add' || kind === 'edit') {
|
||||
// Edits may send only the fields that change: the rest comes from the current task.
|
||||
// Optional keys are defaulted for the same zod 4 reason as above.
|
||||
const merged = { ...(kind === 'edit' ? base : undefined), ...input };
|
||||
const urls = merged.source_urls;
|
||||
const parsed = fieldsSchema.safeParse({
|
||||
...merged,
|
||||
country_code: merged.country_code ?? '',
|
||||
lat: merged.lat ?? '',
|
||||
lon: merged.lon ?? '',
|
||||
status: merged.status ?? (kind === 'add' ? 'open' : undefined),
|
||||
source_urls: Array.isArray(urls) ? urls : typeof urls === 'string' ? [urls] : []
|
||||
});
|
||||
if (parsed.success) fields = parsed.data;
|
||||
else
|
||||
for (const issue of parsed.error.issues)
|
||||
errors[String(issue.path[0] ?? 'form')] ??= issue.message;
|
||||
}
|
||||
if (Object.keys(errors).length || !meta.success) return { ok: false, errors };
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
kind: meta.data.kind,
|
||||
taskSlug: meta.data.task,
|
||||
fields,
|
||||
reason: meta.data.reason,
|
||||
displayName: meta.data.display_name
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** FormData → the raw input record parseRequest takes. */
|
||||
export function formInput(form: FormData): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of [
|
||||
'kind',
|
||||
'task',
|
||||
'title',
|
||||
'description',
|
||||
'scale',
|
||||
'category',
|
||||
'location_name',
|
||||
'country_code',
|
||||
'lat',
|
||||
'lon',
|
||||
'status',
|
||||
'reason',
|
||||
'display_name'
|
||||
]) {
|
||||
const v = form.get(key);
|
||||
if (typeof v === 'string') out[key] = v;
|
||||
}
|
||||
out.source_urls = form.getAll('source_urls').filter((v): v is string => typeof v === 'string');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Honeypot filled in, or submitted faster than a person could. */
|
||||
export function looksLikeBot(honeypot: unknown, startedAt: unknown, now = Date.now()): boolean {
|
||||
if (typeof honeypot === 'string' && honeypot.trim()) return true;
|
||||
const started = Number(startedAt);
|
||||
if (!Number.isFinite(started) || started <= 0) return true;
|
||||
return now - started < MIN_FILL_MS;
|
||||
}
|
||||
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;
|
||||
}
|
||||
12
src/routes/projects/worldwide-todo/+layout.server.ts
Normal file
12
src/routes/projects/worldwide-todo/+layout.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { listCategories, pendingTotal } from '#lib/server/worldtodo/store.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
const owner = isOwner(event);
|
||||
const [categories, pending] = await Promise.all([
|
||||
listCategories(),
|
||||
owner ? pendingTotal() : Promise.resolve(0)
|
||||
]);
|
||||
return { owner, categories, pendingCount: pending };
|
||||
};
|
||||
215
src/routes/projects/worldwide-todo/+layout.svelte
Normal file
215
src/routes/projects/worldwide-todo/+layout.svelte
Normal file
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
|
||||
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
||||
import ListTodo from 'lucide-svelte/icons/list-todo';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import Inbox from 'lucide-svelte/icons/inbox';
|
||||
import { subscribe } from '#lib/worldtodo/live';
|
||||
|
||||
let { data, children } = $props();
|
||||
const base = '/projects/worldwide-todo';
|
||||
|
||||
// Owner: live queue badge over the owner channel, polling every 60 s when it's down.
|
||||
let pending = $state<number | null>(null);
|
||||
const pendingCount = $derived(pending ?? data.pendingCount);
|
||||
$effect(() => {
|
||||
if (!data.owner) return;
|
||||
let up = false;
|
||||
const stop = subscribe(
|
||||
'worldtodo-owner',
|
||||
(msg) => {
|
||||
const m = msg as { type?: string; pending?: number };
|
||||
if (m.type === 'queue.changed' && typeof m.pending === 'number') {
|
||||
pending = m.pending;
|
||||
invalidate('worldtodo:queue');
|
||||
}
|
||||
},
|
||||
(state) => (up = state)
|
||||
);
|
||||
const poll = setInterval(() => {
|
||||
if (!up && document.visibilityState === 'visible') {
|
||||
pending = null;
|
||||
invalidate('worldtodo:queue');
|
||||
}
|
||||
}, 60_000);
|
||||
return () => {
|
||||
stop();
|
||||
clearInterval(poll);
|
||||
};
|
||||
});
|
||||
|
||||
const tabs = $derived([
|
||||
{ key: 'list', name: 'tasks', href: base, icon: ListTodo, locked: false },
|
||||
{
|
||||
key: 'add',
|
||||
name: 'suggest a task',
|
||||
href: `${base}/request?kind=add`,
|
||||
icon: Plus,
|
||||
locked: false
|
||||
},
|
||||
...(data.owner
|
||||
? [{ key: 'queue', name: 'queue', href: `${base}/queue`, icon: Inbox, locked: true }]
|
||||
: [])
|
||||
]);
|
||||
const activeKey = $derived.by(() => {
|
||||
const p = page.url.pathname;
|
||||
if (p.startsWith(`${base}/queue`)) return 'queue';
|
||||
if (p.startsWith(`${base}/request`) && !p.startsWith(`${base}/requests`))
|
||||
return page.url.searchParams.get('kind') === 'add' || !page.url.searchParams.get('kind')
|
||||
? 'add'
|
||||
: '';
|
||||
if (p === base) return 'list';
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="wt-page">
|
||||
<header class="wt-head">
|
||||
<span class="wt-icon"><ProjectIcon name="worldwide todo list" class="size-full" /></span>
|
||||
<div>
|
||||
<p class="eyebrow"><a href={resolve('/projects')}>projects</a></p>
|
||||
<h1>the worldwide todo list</h1>
|
||||
<p class="lede">
|
||||
Real problems, phrased as todos. Anyone can suggest changes; Stan reviews them.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="wt-tabs" aria-label="Worldwide todo list">
|
||||
{#each tabs as tab (tab.key)}
|
||||
{@const active = activeKey === tab.key}
|
||||
<a
|
||||
href={tab.href}
|
||||
class={{ active }}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
data-sveltekit-preload-data="hover"
|
||||
><span class="tab-icon tab-{tab.key}"><tab.icon size={14} aria-hidden="true" /></span
|
||||
>{tab.name}{#if tab.locked}<PrivateLock
|
||||
size={11}
|
||||
/>{/if}{#if tab.key === 'queue' && pendingCount}<span
|
||||
class="badge"
|
||||
aria-label="{pendingCount} pending">{pendingCount}</span
|
||||
>{/if}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="wt-main">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wt-page {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.wt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.wt-icon {
|
||||
display: inline-flex;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.eyebrow a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.eyebrow a:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wt-head h1 {
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.lede {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.wt-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
.wt-tabs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.wt-tabs a:hover,
|
||||
.wt-tabs a:focus-visible {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.wt-tabs a.active {
|
||||
background: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.tab-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
}
|
||||
.tab-list {
|
||||
background: linear-gradient(135deg, #38bdf8, #16a34a);
|
||||
}
|
||||
.tab-add {
|
||||
background: linear-gradient(135deg, #fbbf24, #ea580c);
|
||||
}
|
||||
.tab-queue {
|
||||
background: linear-gradient(135deg, #a78bfa, #db2777);
|
||||
}
|
||||
.badge {
|
||||
min-width: 1.2rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wt-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
35
src/routes/projects/worldwide-todo/+page.server.ts
Normal file
35
src/routes/projects/worldwide-todo/+page.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { counts, listTasks, type Sort } from '#lib/server/worldtodo/store.js';
|
||||
import { WORLDTODO_SCALES } from '#lib/worldtodo/shared.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const STATUSES = ['todo', 'open', 'in_progress', 'done', 'all'];
|
||||
const SORTS: Sort[] = ['updated', 'scale', 'title'];
|
||||
|
||||
// Filters live in the query string (shareable, SSR, works without JS). A GET form submits
|
||||
// toggle buttons that come before hidden copies of the current values, so the first value of
|
||||
// each name wins.
|
||||
export const load: PageServerLoad = async ({ url, parent }) => {
|
||||
const first = (key: string) => url.searchParams.getAll(key)[0]?.trim() ?? '';
|
||||
const { categories } = await parent();
|
||||
const scale = (WORLDTODO_SCALES as readonly string[]).includes(first('scale'))
|
||||
? first('scale')
|
||||
: '';
|
||||
const category = categories.some((c) => c.slug === first('category')) ? first('category') : '';
|
||||
const status = STATUSES.includes(first('status')) ? first('status') : 'todo';
|
||||
const sort = (SORTS as string[]).includes(first('sort')) ? (first('sort') as Sort) : 'updated';
|
||||
const q = first('q').slice(0, 100);
|
||||
const filter = { scale, category, status, q };
|
||||
const [list, totals] = await Promise.all([listTasks(filter, sort, 500), counts(filter)]);
|
||||
return {
|
||||
filter,
|
||||
sort,
|
||||
tasks: list.tasks,
|
||||
counts: totals,
|
||||
seo: {
|
||||
title: 'The worldwide todo list',
|
||||
description:
|
||||
'A public todo list of real, sourced problems, from planet-scale to a single street. Anyone can suggest additions, edits and removals.',
|
||||
index: !scale && !category && status === 'todo' && !q && sort === 'updated'
|
||||
}
|
||||
};
|
||||
};
|
||||
470
src/routes/projects/worldwide-todo/+page.svelte
Normal file
470
src/routes/projects/worldwide-todo/+page.svelte
Normal file
@@ -0,0 +1,470 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TaskCard from '#lib/worldtodo/TaskCard.svelte';
|
||||
import IconTile from '#lib/worldtodo/IconTile.svelte';
|
||||
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
|
||||
import { subscribe } from '#lib/worldtodo/live';
|
||||
import {
|
||||
SCALE_INFO,
|
||||
WORLDTODO_SCALES,
|
||||
matchesFilters,
|
||||
type LiveEvent,
|
||||
type PublicTask
|
||||
} from '#lib/worldtodo/shared';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const catBySlug = $derived(new Map(data.categories.map((c) => [c.slug, c])));
|
||||
const statusTabs = [
|
||||
{ key: 'todo', label: 'To do' },
|
||||
{ key: 'open', label: 'Open' },
|
||||
{ key: 'in_progress', label: 'In progress' },
|
||||
{ key: 'done', label: 'Done (wins)' },
|
||||
{ key: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
// Realtime: approved changes patch the SSR list in place until the next navigation.
|
||||
let overrides = $state<Record<string, PublicTask | null>>({});
|
||||
let added = $state<PublicTask[]>([]);
|
||||
let flashing = $state<string | null>(null);
|
||||
let announce = $state('');
|
||||
let now = $state(Date.now());
|
||||
$effect(() => {
|
||||
void data.tasks;
|
||||
untrack(() => {
|
||||
overrides = {};
|
||||
added = [];
|
||||
});
|
||||
});
|
||||
const tasks = $derived.by(() => {
|
||||
const base = data.tasks
|
||||
.map((t) => (t.slug in overrides ? overrides[t.slug] : t))
|
||||
.filter((t): t is PublicTask => !!t);
|
||||
const extra = added.filter((t) => !base.some((b) => b.slug === t.slug));
|
||||
return [...extra, ...base].filter((t) => matchesFilters(t, data.filter));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const tick = setInterval(() => (now = Date.now()), 60_000);
|
||||
let flashTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stop = subscribe('worldtodo', (msg) => {
|
||||
const e = msg as LiveEvent;
|
||||
if (!e?.slug || !e.type?.startsWith('task.')) return;
|
||||
const inList = data.tasks.some((t) => t.slug === e.slug);
|
||||
if (e.type === 'task.deleted' || !e.task) {
|
||||
overrides = { ...overrides, [e.slug]: null };
|
||||
added = added.filter((t) => t.slug !== e.slug);
|
||||
announce = 'A task was removed.';
|
||||
return;
|
||||
}
|
||||
if (inList) overrides = { ...overrides, [e.slug]: e.task };
|
||||
else added = [e.task, ...added.filter((t) => t.slug !== e.slug)];
|
||||
announce = `${e.type === 'task.added' ? 'Task added' : 'Task updated'}: ${e.task.title}`;
|
||||
flashing = e.slug;
|
||||
clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => (flashing = null), 2500);
|
||||
});
|
||||
return () => {
|
||||
stop();
|
||||
clearInterval(tick);
|
||||
clearTimeout(flashTimer);
|
||||
};
|
||||
});
|
||||
|
||||
// Search: debounced 300 ms, submits the GET form (focus and scroll are kept).
|
||||
let form: HTMLFormElement;
|
||||
let searchEl: HTMLInputElement;
|
||||
let q = $state(untrack(() => data.filter.q));
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
$effect(() => {
|
||||
const next = data.filter.q;
|
||||
untrack(() => {
|
||||
if (document.activeElement !== searchEl) q = next;
|
||||
});
|
||||
});
|
||||
const onSearch = () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => form.requestSubmit(), 300);
|
||||
};
|
||||
|
||||
const f = $derived(data.filter);
|
||||
const total = $derived(data.counts.status.open + data.counts.status.in_progress);
|
||||
</script>
|
||||
|
||||
<section class="hero" aria-label="Totals">
|
||||
<p>
|
||||
<strong>{data.counts.status.open}</strong> open tasks ·
|
||||
<strong>{data.counts.status.in_progress}</strong> in progress ·
|
||||
<strong>{data.counts.status.done}</strong> done
|
||||
</p>
|
||||
<p class="sub">
|
||||
{total} real, sourced problems from planet-scale down to a single street. Spot something wrong, outdated
|
||||
or missing? Suggest a change; nothing changes until it's reviewed.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<form
|
||||
bind:this={form}
|
||||
method="GET"
|
||||
class="filters"
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll
|
||||
aria-label="Filter tasks"
|
||||
>
|
||||
<div class="scales" role="group" aria-label="Scale">
|
||||
{#each WORLDTODO_SCALES as s (s)}
|
||||
{@const on = f.scale === s}
|
||||
<button
|
||||
type="submit"
|
||||
name="scale"
|
||||
value={on ? '' : s}
|
||||
class={['scale', { on }]}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<IconTile icon={SCALE_ICONS[s].icon} background={SCALE_ICONS[s].gradient} size="lg" />
|
||||
<span class="scale-label">{SCALE_INFO[s].label}</span>
|
||||
<span class="count">{data.counts.scale[s] ?? 0}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="cats" role="group" aria-label="Category">
|
||||
{#each data.categories as c (c.slug)}
|
||||
{@const on = f.category === c.slug}
|
||||
<button
|
||||
type="submit"
|
||||
name="category"
|
||||
value={on ? '' : c.slug}
|
||||
class={['chip', { on }]}
|
||||
aria-pressed={on}
|
||||
><IconTile icon={categoryIcon(c.icon)} background={c.colour} size="sm" />{c.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
<div class="seg" role="group" aria-label="Status">
|
||||
{#each statusTabs as s (s.key)}
|
||||
<button
|
||||
type="submit"
|
||||
name="status"
|
||||
value={s.key}
|
||||
class={{ on: f.status === s.key }}
|
||||
aria-pressed={f.status === s.key}>{s.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="search">
|
||||
<span class="sr-only">Search tasks</span>
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input
|
||||
bind:this={searchEl}
|
||||
type="search"
|
||||
name="q"
|
||||
bind:value={q}
|
||||
oninput={onSearch}
|
||||
placeholder="Search tasks, places…"
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sort">
|
||||
<span>Sort</span>
|
||||
<select name="sort" value={data.sort} onchange={() => form.requestSubmit()}>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="scale">Scale</option>
|
||||
<option value="title">Title</option>
|
||||
</select>
|
||||
</label>
|
||||
<noscript><button type="submit" class="go">Apply</button></noscript>
|
||||
</div>
|
||||
|
||||
<!-- Current values, after the buttons so a pressed button's value wins. -->
|
||||
{#if f.scale}<input type="hidden" name="scale" value={f.scale} />{/if}
|
||||
{#if f.category}<input type="hidden" name="category" value={f.category} />{/if}
|
||||
{#if f.status !== 'todo'}<input type="hidden" name="status" value={f.status} />{/if}
|
||||
</form>
|
||||
|
||||
<p class="results" aria-live="polite">
|
||||
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q} matching “{f.q}”{/if}
|
||||
{#if f.scale || f.category || f.q || f.status !== 'todo'}
|
||||
· <a href="/projects/worldwide-todo" data-sveltekit-noscroll>clear filters</a>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="sr-only" aria-live="polite">{announce}</p>
|
||||
|
||||
<ul class="grid">
|
||||
<li>
|
||||
<a class="suggest" href="/projects/worldwide-todo/request?kind=add">
|
||||
<span class="plus"><Plus size={22} aria-hidden="true" /></span>
|
||||
<h3>Suggest a task</h3>
|
||||
<span>Phrase it as something to do and link a source. It shows up here once approved.</span>
|
||||
</a>
|
||||
</li>
|
||||
{#each tasks as task (task.slug)}
|
||||
<li>
|
||||
<TaskCard
|
||||
{task}
|
||||
category={catBySlug.get(task.category)}
|
||||
flash={flashing === task.slug}
|
||||
{now}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Nothing matches these filters.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hero p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.hero strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hero .sub {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
max-width: 46rem;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.scales {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.scale {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0.4rem 0.6rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.85rem;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.scale:hover,
|
||||
.scale:focus-visible {
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.scale.on {
|
||||
border-color: var(--site-text);
|
||||
box-shadow: inset 0 0 0 1px var(--site-text);
|
||||
}
|
||||
.scale-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 550;
|
||||
}
|
||||
.count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.chip:hover,
|
||||
.chip:focus-visible {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.chip.on {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.seg button {
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button:hover,
|
||||
.seg button:focus-visible {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.seg button.on {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 14rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--site-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.sort select,
|
||||
.go {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.results {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.results a {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
.suggest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
height: 100%;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border: 1px dashed var(--site-muted);
|
||||
border-radius: 0.85rem;
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.suggest:hover,
|
||||
.suggest:focus-visible {
|
||||
border-color: var(--site-text);
|
||||
border-style: solid;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.suggest h3 {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.suggest span:last-child {
|
||||
font-size: 0.83rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.plus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #fbbf24, #ea580c);
|
||||
}
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.scales {
|
||||
grid-template-columns: repeat(5, minmax(4.2rem, 1fr));
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.scale {
|
||||
padding: 0.55rem 0.2rem 0.45rem;
|
||||
}
|
||||
.scale :global(.lg) {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
}
|
||||
.cats {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.chip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scale,
|
||||
.suggest {
|
||||
transition: none;
|
||||
}
|
||||
.scale:hover,
|
||||
.scale:focus-visible,
|
||||
.suggest:hover,
|
||||
.suggest:focus-visible {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
src/routes/projects/worldwide-todo/[slug]/+page.server.ts
Normal file
19
src/routes/projects/worldwide-todo/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getTask, taskActivity } from '#lib/server/worldtodo/store.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
if (params.slug.length > 120) error(404, 'Not Found');
|
||||
const found = await getTask(params.slug);
|
||||
if (!found) error(404, 'Not Found');
|
||||
const activity = await taskActivity(found.task.id);
|
||||
return {
|
||||
task: found.task,
|
||||
history: activity.history,
|
||||
pending: activity.pending,
|
||||
seo: {
|
||||
title: `${found.task.title} · worldwide todo list`,
|
||||
description: found.task.description.slice(0, 200)
|
||||
}
|
||||
};
|
||||
};
|
||||
502
src/routes/projects/worldwide-todo/[slug]/+page.svelte
Normal file
502
src/routes/projects/worldwide-todo/[slug]/+page.svelte
Normal file
@@ -0,0 +1,502 @@
|
||||
<script lang="ts">
|
||||
import IconTile from '#lib/worldtodo/IconTile.svelte';
|
||||
import StatusPill from '#lib/worldtodo/StatusPill.svelte';
|
||||
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
|
||||
import { subscribe } from '#lib/worldtodo/live';
|
||||
import { invalidateAll, goto } from '$app/navigation';
|
||||
import { SCALE_INFO, relativeTime, type LiveEvent } from '#lib/worldtodo/shared';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
|
||||
let { data } = $props();
|
||||
const t = $derived(data.task);
|
||||
const cat = $derived(data.categories.find((c) => c.slug === t.category));
|
||||
const scale = $derived(SCALE_ICONS[t.scale]);
|
||||
let now = $state(Date.now());
|
||||
let announce = $state('');
|
||||
let flash = $state(false);
|
||||
let removed = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const slug = data.task.slug;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stop = subscribe('worldtodo', (msg) => {
|
||||
const e = msg as LiveEvent;
|
||||
if (e?.slug !== slug) return;
|
||||
if (e.type === 'task.deleted') {
|
||||
removed = true;
|
||||
announce = 'This task was removed.';
|
||||
return;
|
||||
}
|
||||
announce = `Task updated: ${e.task?.title ?? ''}`;
|
||||
flash = true;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => (flash = false), 2500);
|
||||
invalidateAll();
|
||||
});
|
||||
const tick = setInterval(() => (now = Date.now()), 60_000);
|
||||
return () => {
|
||||
stop();
|
||||
clearInterval(tick);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
});
|
||||
|
||||
// Static OSM map: 3×3 tiles around the point with a marker; no scripts, no tracking.
|
||||
const ZOOM = { planet: 2, region: 4, country: 5, city: 11, street: 15 } as const;
|
||||
const map = $derived.by(() => {
|
||||
if (t.lat === null || t.lon === null) return null;
|
||||
const z = ZOOM[t.scale];
|
||||
const n = 2 ** z;
|
||||
const x = ((t.lon + 180) / 360) * n;
|
||||
const latR = (t.lat * Math.PI) / 180;
|
||||
const y = ((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2) * n;
|
||||
const cx = Math.floor(x);
|
||||
const cy = Math.floor(y);
|
||||
const tiles: { src: string; left: number; top: number }[] = [];
|
||||
for (let dy = -1; dy <= 1; dy++)
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const ty = cy + dy;
|
||||
if (ty < 0 || ty >= n) continue;
|
||||
const tx = (((cx + dx) % n) + n) % n;
|
||||
tiles.push({
|
||||
src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`,
|
||||
left: (dx + 1) * 256,
|
||||
top: (dy + 1) * 256
|
||||
});
|
||||
}
|
||||
// Marker position within the 768×768 grid.
|
||||
const mx = (x - cx + 1) * 256;
|
||||
const my = (y - cy + 1) * 256;
|
||||
return {
|
||||
tiles,
|
||||
mx,
|
||||
my,
|
||||
link: `https://www.openstreetmap.org/?mlat=${t.lat}&mlon=${t.lon}#map=${z}/${t.lat}/${t.lon}`
|
||||
};
|
||||
});
|
||||
const host = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
const fmtDate = (iso: string) =>
|
||||
new Date(iso.length === 10 ? `${iso}T12:00:00Z` : iso).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/Amsterdam'
|
||||
});
|
||||
const kindLabel = { add: 'Added', edit: 'Edited', delete: 'Removed' } as const;
|
||||
const pendingLabel = { add: 'addition', edit: 'edit', delete: 'removal request' } as const;
|
||||
</script>
|
||||
|
||||
<p class="sr-only" aria-live="polite">{announce}</p>
|
||||
<a class="back" href="/projects/worldwide-todo"
|
||||
><ArrowLeft size={14} aria-hidden="true" />all tasks</a
|
||||
>
|
||||
|
||||
{#if removed}
|
||||
<div class="notice" role="status">
|
||||
This task was just removed. <button
|
||||
type="button"
|
||||
onclick={() => goto('/projects/worldwide-todo')}>Back to the list</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<article class={['task', { flash }]}>
|
||||
<div class="tags">
|
||||
<span class="tag"
|
||||
><IconTile icon={scale.icon} background={scale.gradient} />{SCALE_INFO[t.scale].label}</span
|
||||
>
|
||||
<span class="tag"
|
||||
><IconTile
|
||||
icon={categoryIcon(cat?.icon)}
|
||||
background={cat?.colour ?? '#737373'}
|
||||
/>{cat?.label ?? t.category}</span
|
||||
>
|
||||
<StatusPill status={t.status} />
|
||||
{#if t.confidence === 'medium'}<span class="conf" title={t.verificationNote ?? ''}
|
||||
>medium confidence</span
|
||||
>{/if}
|
||||
</div>
|
||||
<h2>{t.title}</h2>
|
||||
<p class="where">
|
||||
{t.locationName}{#if t.countryCode} · {t.countryCode}{/if}
|
||||
</p>
|
||||
|
||||
{#if data.pending.length}
|
||||
<div class="pending" role="note">
|
||||
<Clock size={15} aria-hidden="true" />
|
||||
<div>
|
||||
<strong
|
||||
>{data.pending.length} pending {data.pending.length === 1
|
||||
? pendingLabel[data.pending[0].kind]
|
||||
: 'requests'}</strong
|
||||
>
|
||||
<ul>
|
||||
{#each data.pending as p (p.id)}
|
||||
<li>{p.summary} · {relativeTime(p.at, now) || 'just now'}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="hint">Already covered? No need to file it again.</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="body">
|
||||
<div class="main">
|
||||
<p class="desc">{t.description}</p>
|
||||
<h3>Sources</h3>
|
||||
<ul class="sources">
|
||||
{#each t.sourceUrls as url (url)}
|
||||
<li>
|
||||
<a href={url} target="_blank" rel="noopener nofollow noreferrer"
|
||||
><span>{host(url)}</span><ArrowUpRight size={14} aria-hidden="true" /></a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="asof">
|
||||
Facts as of {fmtDate(t.asOf)}{#if t.sourceDate} · source dated {t.sourceDate}{/if} · updated
|
||||
{relativeTime(t.updatedAt, now) || 'just now'}
|
||||
</p>
|
||||
{#if t.verificationNote}<p class="note">Note: {t.verificationNote}</p>{/if}
|
||||
<div class="actions">
|
||||
<a class="btn" href="/projects/worldwide-todo/request?kind=edit&task={t.slug}"
|
||||
><Pencil size={15} aria-hidden="true" />Suggest edit</a
|
||||
>
|
||||
<a class="btn" href="/projects/worldwide-todo/request?kind=delete&task={t.slug}"
|
||||
><Trash2 size={15} aria-hidden="true" />Request removal</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if map}
|
||||
<a
|
||||
class="map"
|
||||
href={map.link}
|
||||
target="_blank"
|
||||
rel="noopener nofollow noreferrer"
|
||||
aria-label="Open {t.locationName} on OpenStreetMap"
|
||||
>
|
||||
<div class="tiles" style:--mx="{map.mx}px" style:--my="{map.my}px">
|
||||
{#each map.tiles as tile (tile.src)}
|
||||
<img
|
||||
src={tile.src}
|
||||
alt=""
|
||||
width="256"
|
||||
height="256"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
style:left="{tile.left}px"
|
||||
style:top="{tile.top}px"
|
||||
/>
|
||||
{/each}
|
||||
<span class="marker" aria-hidden="true"></span>
|
||||
</div>
|
||||
<span class="attrib">© OpenStreetMap contributors</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="history" aria-labelledby="history-h">
|
||||
<h3 id="history-h"><History size={15} aria-hidden="true" />History</h3>
|
||||
<ol>
|
||||
{#each data.history as h (h.id)}
|
||||
<li>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span
|
||||
><strong>{kindLabel[h.kind]}</strong> · {h.summary}{#if h.ownerNote}<span class="onote">
|
||||
— “{h.ownerNote}”</span
|
||||
>{/if}</span
|
||||
>
|
||||
<time datetime={h.at}>{fmtDate(h.at)}</time>
|
||||
</li>
|
||||
{/each}
|
||||
<li>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span><strong>Listed</strong> · on the worldwide todo list</span>
|
||||
<time datetime={t.createdAt}>{fmtDate(t.createdAt)}</time>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
text-decoration: none;
|
||||
width: fit-content;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.notice {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--site-text);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.notice button {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--site-surface);
|
||||
transition: background-color 0.6s;
|
||||
}
|
||||
.task.flash {
|
||||
background: color-mix(in srgb, var(--site-surface) 85%, #facc15);
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 550;
|
||||
}
|
||||
.conf {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border: 1px dashed var(--site-muted);
|
||||
border-radius: 999px;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(1.25rem, 3.5vw, 1.6rem);
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.where {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.pending {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--site-text);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
.pending ul {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.body:not(:has(.map)) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
max-width: 44rem;
|
||||
}
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--site-muted);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.sources a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
.sources a span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sources a:hover,
|
||||
.sources a:focus-visible {
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.asof,
|
||||
.note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.btn:hover,
|
||||
.btn:focus-visible {
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.map {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.85rem;
|
||||
filter: grayscale(0.6);
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
.map:hover,
|
||||
.map:focus-visible {
|
||||
filter: none;
|
||||
}
|
||||
.tiles {
|
||||
position: absolute;
|
||||
width: 768px;
|
||||
height: 768px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(calc(-1 * var(--mx)), calc(-1 * var(--my)));
|
||||
}
|
||||
.tiles img {
|
||||
position: absolute;
|
||||
max-width: none;
|
||||
}
|
||||
.marker {
|
||||
position: absolute;
|
||||
left: var(--mx);
|
||||
top: var(--my);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -9px 0 0 -9px;
|
||||
border-radius: 999px;
|
||||
background: #dc2626;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 0 2px #000;
|
||||
}
|
||||
.attrib {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.6rem;
|
||||
background: rgb(255 255 255 / 0.8);
|
||||
color: #000;
|
||||
}
|
||||
.history ol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border-left: 1px solid var(--site-border);
|
||||
padding-left: 0.9rem;
|
||||
}
|
||||
.history li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.history .dot {
|
||||
position: absolute;
|
||||
left: calc(-0.9rem - 4.5px);
|
||||
top: 0.4rem;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--site-text);
|
||||
}
|
||||
.history time,
|
||||
.onote {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.map {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn,
|
||||
.sources a,
|
||||
.task {
|
||||
transition: none;
|
||||
}
|
||||
.btn:hover,
|
||||
.sources a:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listCategories } from '#lib/server/worldtodo/store.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () =>
|
||||
json(await listCategories(), { headers: { 'cache-control': 'public, max-age=300' } });
|
||||
12
src/routes/projects/worldwide-todo/api/queue/+server.ts
Normal file
12
src/routes/projects/worldwide-todo/api/queue/+server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { queue } from '#lib/server/worldtodo/store.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Owner: full requests with the current task (for diffs), stale flags and similar titles.
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const raw = event.url.searchParams.get('status') ?? 'pending';
|
||||
const status = (['pending', 'approved', 'rejected'] as const).find((s) => s === raw) ?? 'pending';
|
||||
return json(await queue(status), { headers: { 'cache-control': 'private, no-store' } });
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { approveRequest } from '#lib/server/worldtodo/decide.js';
|
||||
import { DecisionError } from '#lib/server/worldtodo/store.js';
|
||||
import { fieldsSchema } from '#lib/server/worldtodo/validate.js';
|
||||
import type { TaskFields } from '#lib/worldtodo/shared.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Owner: body { note?, payload? } where payload is the full tweaked field set.
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const id = Number(event.params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) error(404, 'Not Found');
|
||||
const body = ((await event.request.json().catch(() => ({}))) ?? {}) as {
|
||||
note?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
let tweaked: TaskFields | null = null;
|
||||
if (body.payload) {
|
||||
const parsed = fieldsSchema.safeParse(body.payload);
|
||||
if (!parsed.success)
|
||||
return json({ error: parsed.error.issues.map((i) => i.message) }, { status: 400 });
|
||||
tweaked = parsed.data as TaskFields;
|
||||
}
|
||||
try {
|
||||
const applied = await approveRequest(id, body.note, tweaked);
|
||||
return json({ ok: true, ...applied });
|
||||
} catch (e) {
|
||||
if (e instanceof DecisionError) return json({ error: e.message }, { status: e.status });
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { rejectRequest } from '#lib/server/worldtodo/decide.js';
|
||||
import { DecisionError } from '#lib/server/worldtodo/store.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Owner: body { note? }.
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const id = Number(event.params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) error(404, 'Not Found');
|
||||
const body = ((await event.request.json().catch(() => ({}))) ?? {}) as { note?: string };
|
||||
try {
|
||||
await rejectRequest(id, body.note);
|
||||
return json({ ok: true });
|
||||
} catch (e) {
|
||||
if (e instanceof DecisionError) return json({ error: e.message }, { status: e.status });
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
41
src/routes/projects/worldwide-todo/api/requests/+server.ts
Normal file
41
src/routes/projects/worldwide-todo/api/requests/+server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { clientIp } from '#lib/server/worldtodo/limit.js';
|
||||
import { submitRequest } from '#lib/server/worldtodo/requests.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// POST /projects/worldwide-todo/api/requests (public + Turnstile). JSON body:
|
||||
// { kind, task?, title?, description?, scale?, category?, location_name?, country_code?, lat?,
|
||||
// lon?, status?, source_urls?: string[], reason, display_name?, started, website?, turnstile }
|
||||
// 201 { id, statusUrl } | 400 { error, fields? } | 429 (Retry-After) | 503.
|
||||
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||||
if (!request.headers.get('content-type')?.includes('application/json'))
|
||||
return json({ error: 'Send JSON' }, { status: 415 });
|
||||
const text = await request.text();
|
||||
if (text.length > 20_000) return json({ error: 'Too large' }, { status: 413 });
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
const result = await submitRequest({
|
||||
input: body,
|
||||
honeypot: body.website,
|
||||
startedAt: body.started,
|
||||
token: typeof body.turnstile === 'string' ? body.turnstile : '',
|
||||
ip: clientIp(request, getClientAddress)
|
||||
});
|
||||
if (result.ok)
|
||||
return json(
|
||||
{ id: result.id, statusUrl: `/projects/worldwide-todo/requests/${result.id}` },
|
||||
{ status: 201 }
|
||||
);
|
||||
return json(
|
||||
{ error: result.message, ...(result.errors ? { fields: result.errors } : {}) },
|
||||
{
|
||||
status: result.status,
|
||||
headers: result.retryAfter ? { 'retry-after': String(result.retryAfter) } : undefined
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requestStatus } from '#lib/server/worldtodo/store.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Minimal public status: kind, status, created_at, decided_at, owner_note.
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
if (!/^\d{1,12}$/.test(params.id)) error(404, 'Not Found');
|
||||
const r = await requestStatus(Number(params.id));
|
||||
if (!r) error(404, 'Not Found');
|
||||
return json({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
status: r.status,
|
||||
created_at: r.createdAt,
|
||||
decided_at: r.decidedAt,
|
||||
owner_note: r.ownerNote
|
||||
});
|
||||
};
|
||||
26
src/routes/projects/worldwide-todo/api/tasks/+server.ts
Normal file
26
src/routes/projects/worldwide-todo/api/tasks/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listTasks, type Sort } from '#lib/server/worldtodo/store.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// GET /projects/worldwide-todo/api/tasks?scale=&category=&status=&q=&sort=&cursor=
|
||||
// 50 per page, keyset cursor (nextCursor), deleted tasks excluded. status defaults to 'todo'
|
||||
// (open + in progress); 'all', 'open', 'in_progress' or 'done' otherwise.
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const p = (k: string) => url.searchParams.get(k)?.trim() ?? '';
|
||||
const scale = ['planet', 'region', 'country', 'city', 'street'].includes(p('scale'))
|
||||
? p('scale')
|
||||
: '';
|
||||
const status = ['todo', 'open', 'in_progress', 'done', 'all'].includes(p('status'))
|
||||
? p('status')
|
||||
: 'todo';
|
||||
const sort: Sort =
|
||||
(['updated', 'scale', 'title'] as const).find((s) => s === p('sort')) ?? 'updated';
|
||||
const category = /^[a-z][a-z0-9-]{1,30}$/.test(p('category')) ? p('category') : '';
|
||||
const page = await listTasks(
|
||||
{ scale, category, status, q: p('q').slice(0, 100) },
|
||||
sort,
|
||||
50,
|
||||
p('cursor').slice(0, 500) || undefined
|
||||
);
|
||||
return json(page, { headers: { 'cache-control': 'public, max-age=30' } });
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { editTaskDirectly } from '#lib/server/worldtodo/decide.js';
|
||||
import {
|
||||
DecisionError,
|
||||
currentFields,
|
||||
getTask,
|
||||
taskActivity
|
||||
} from '#lib/server/worldtodo/store.js';
|
||||
import { fieldsSchema } from '#lib/server/worldtodo/validate.js';
|
||||
import type { TaskFields } from '#lib/worldtodo/shared.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// GET: the task, its approved history and pending requests (summaries only).
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const found = await getTask(params.slug);
|
||||
if (!found) error(404, 'Not Found');
|
||||
const activity = await taskActivity(found.task.id);
|
||||
return json({
|
||||
task: found.task,
|
||||
history: activity.history,
|
||||
pendingCount: activity.pending.length,
|
||||
pending: activity.pending
|
||||
});
|
||||
};
|
||||
|
||||
// PATCH (owner): direct edit, body { fields: Partial<TaskFields>, note? }. Logged as an
|
||||
// auto-approved edit request; published like any approval.
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const body = (await event.request.json().catch(() => null)) as {
|
||||
fields?: Record<string, unknown>;
|
||||
note?: string;
|
||||
} | null;
|
||||
if (!body?.fields || typeof body.fields !== 'object') error(400, 'Body needs { fields }');
|
||||
const found = await getTask(event.params.slug);
|
||||
if (!found) error(404, 'Not Found');
|
||||
const parsed = fieldsSchema.safeParse({ ...currentFields(found.task), ...body.fields });
|
||||
if (!parsed.success)
|
||||
return json({ error: parsed.error.issues.map((i) => i.message) }, { status: 400 });
|
||||
try {
|
||||
const applied = await editTaskDirectly(event.params.slug, parsed.data as TaskFields, body.note);
|
||||
const fresh = await getTask(applied.slug);
|
||||
return json({ ok: true, task: fresh?.task ?? null });
|
||||
} catch (e) {
|
||||
if (e instanceof DecisionError) return json({ error: e.message }, { status: e.status });
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
79
src/routes/projects/worldwide-todo/queue/+page.server.ts
Normal file
79
src/routes/projects/worldwide-todo/queue/+page.server.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { approveRequest, rejectRequest } from '#lib/server/worldtodo/decide.js';
|
||||
import { DecisionError, queue } from '#lib/server/worldtodo/store.js';
|
||||
import { fieldsSchema, formInput } from '#lib/server/worldtodo/validate.js';
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
import type { TaskFields } from '#lib/worldtodo/shared.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
// Owner moderation queue. Owner-only three times over: hooks.server.ts refuses non-GET
|
||||
// requests from anyone else (ownerOnlyPrefixes), and every load and action calls requireOwner,
|
||||
// which answers 404 like an unknown URL. SvelteKit's origin check covers CSRF.
|
||||
|
||||
const TABS = ['pending', 'approved', 'rejected'] as const;
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
event.depends('worldtodo:queue');
|
||||
const raw = event.url.searchParams.get('status') ?? 'pending';
|
||||
const status = (TABS as readonly string[]).includes(raw)
|
||||
? (raw as (typeof TABS)[number])
|
||||
: 'pending';
|
||||
const [items, totals] = await Promise.all([
|
||||
queue(status),
|
||||
sql`select status::text as status, count(*)::int as n from worldtodo.change_requests group by status`
|
||||
]);
|
||||
const counts: Record<string, number> = { pending: 0, approved: 0, rejected: 0 };
|
||||
for (const r of totals) counts[r.status] = r.n;
|
||||
return {
|
||||
status,
|
||||
items,
|
||||
counts,
|
||||
seo: { title: 'Queue · worldwide todo list', description: 'Owner queue', index: false }
|
||||
};
|
||||
};
|
||||
|
||||
const idOf = (form: FormData) => {
|
||||
const id = Number(form.get('id'));
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
};
|
||||
|
||||
async function run(id: number | null, fn: (id: number) => Promise<unknown>, done: string) {
|
||||
if (!id) return fail(400, { id, message: 'Missing request id' });
|
||||
try {
|
||||
await fn(id);
|
||||
return { id, ok: done };
|
||||
} catch (e) {
|
||||
if (e instanceof DecisionError) return fail(e.status, { id, message: e.message });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
approve: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
return run(idOf(form), (id) => approveRequest(id, form.get('note')), 'Approved');
|
||||
},
|
||||
tweak: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const parsed = fieldsSchema.safeParse(formInput(form));
|
||||
if (!parsed.success)
|
||||
return fail(400, {
|
||||
id: idOf(form),
|
||||
message: parsed.error.issues.map((i) => i.message).join(' · ')
|
||||
});
|
||||
return run(
|
||||
idOf(form),
|
||||
(id) => approveRequest(id, form.get('note'), parsed.data as TaskFields),
|
||||
'Approved with tweaks'
|
||||
);
|
||||
},
|
||||
reject: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
return run(idOf(form), (id) => rejectRequest(id, form.get('note')), 'Rejected');
|
||||
}
|
||||
};
|
||||
576
src/routes/projects/worldwide-todo/queue/+page.svelte
Normal file
576
src/routes/projects/worldwide-todo/queue/+page.svelte
Normal file
@@ -0,0 +1,576 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import TaskFieldsEditor from '#lib/worldtodo/TaskFieldsEditor.svelte';
|
||||
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
||||
import {
|
||||
EDITABLE_FIELDS,
|
||||
FIELD_LABELS,
|
||||
fieldsOf,
|
||||
formatValue,
|
||||
relativeTime,
|
||||
type EditableField
|
||||
} from '#lib/worldtodo/shared';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import PencilLine from 'lucide-svelte/icons/pencil-line';
|
||||
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
|
||||
import Copy from 'lucide-svelte/icons/copy';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
type Item = (typeof data.items)[number];
|
||||
const tabs = [
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'approved', label: 'Approved' },
|
||||
{ key: 'rejected', label: 'Rejected' }
|
||||
] as const;
|
||||
const kindLabel = { add: 'Add', edit: 'Edit', delete: 'Delete' } as const;
|
||||
|
||||
let selected = $state(0);
|
||||
let tweaking = $state<number | null>(null);
|
||||
let busy = $state<number | null>(null);
|
||||
let now = $state(Date.now());
|
||||
const cards: HTMLElement[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
const t = setInterval(() => (now = Date.now()), 30_000);
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
$effect(() => {
|
||||
if (selected >= data.items.length) selected = Math.max(0, data.items.length - 1);
|
||||
});
|
||||
|
||||
/** Field rows: old → new. For decided requests, what was applied. */
|
||||
function diff(item: Item) {
|
||||
const change = (
|
||||
item.status === 'pending' ? item.payload : (item.applied ?? item.payload)
|
||||
) as Record<string, unknown>;
|
||||
const current = item.task ? fieldsOf(item.task) : null;
|
||||
if (item.kind === 'delete')
|
||||
return current
|
||||
? (['title', 'location_name', 'status'] as EditableField[]).map((k) => ({
|
||||
key: k,
|
||||
old: formatValue(k, current[k]),
|
||||
new: '—'
|
||||
}))
|
||||
: [];
|
||||
return EDITABLE_FIELDS.filter((k) => k in change).map((k) => ({
|
||||
key: k,
|
||||
old: item.kind === 'add' || !current ? '—' : formatValue(k, current[k]),
|
||||
new: formatValue(k, change[k])
|
||||
}));
|
||||
}
|
||||
function tweakValues(item: Item) {
|
||||
const base = item.task ? fieldsOf(item.task) : {};
|
||||
return { ...base, ...item.payload } as Record<string, unknown>;
|
||||
}
|
||||
const sources = (item: Item) =>
|
||||
((item.payload.source_urls as string[] | undefined) ?? item.task?.sourceUrls ?? []).slice(0, 5);
|
||||
|
||||
function submit(i: number, action: 'approve' | 'reject') {
|
||||
const card = cards[i];
|
||||
const btn = card?.querySelector<HTMLButtonElement>(`button[data-action="${action}"]`);
|
||||
if (btn && !btn.disabled) btn.click();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const el = e.target as HTMLElement;
|
||||
if (el.closest('input, textarea, select, [contenteditable]')) return;
|
||||
if (data.status !== 'pending' || !data.items.length) return;
|
||||
if (e.key === 'j' || e.key === 'k') {
|
||||
e.preventDefault();
|
||||
selected = Math.min(data.items.length - 1, Math.max(0, selected + (e.key === 'j' ? 1 : -1)));
|
||||
cards[selected]?.focus();
|
||||
cards[selected]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
} else if (e.key === 'a') {
|
||||
e.preventDefault();
|
||||
submit(selected, 'approve');
|
||||
} else if (e.key === 'r') {
|
||||
e.preventDefault();
|
||||
submit(selected, 'reject');
|
||||
} else if (e.key === 'e') {
|
||||
e.preventDefault();
|
||||
tweaking = data.items[selected]?.id ?? null;
|
||||
}
|
||||
}
|
||||
const host = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<section class="queue" aria-labelledby="queue-h">
|
||||
<div class="top">
|
||||
<h2 id="queue-h">Review queue <PrivateLock size={13} /></h2>
|
||||
{#if data.status === 'pending'}
|
||||
<p class="keys" aria-label="Keyboard shortcuts">
|
||||
<kbd>j</kbd>/<kbd>k</kbd> move · <kbd>a</kbd> approve · <kbd>e</kbd> edit & approve ·
|
||||
<kbd>r</kbd> reject
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Request status">
|
||||
{#each tabs as t (t.key)}
|
||||
<a
|
||||
href="?status={t.key}"
|
||||
class={{ on: data.status === t.key }}
|
||||
aria-current={data.status === t.key ? 'page' : undefined}
|
||||
>{t.label} <span class="n">{data.counts[t.key]}</span></a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if form?.ok}<p class="flash" role="status">{form.ok} #{form.id}.</p>{/if}
|
||||
|
||||
{#if !data.items.length}
|
||||
<p class="empty">Nothing {data.status} right now.</p>
|
||||
{:else}
|
||||
<ol class="items">
|
||||
{#each data.items as item, i (item.id)}
|
||||
<li>
|
||||
<article
|
||||
class={['card', { sel: i === selected && data.status === 'pending' }]}
|
||||
tabindex="-1"
|
||||
bind:this={cards[i]}
|
||||
onfocusin={() => (selected = i)}
|
||||
aria-labelledby="req-{item.id}-h"
|
||||
>
|
||||
<header>
|
||||
<span class="kind {item.kind}">{kindLabel[item.kind]}</span>
|
||||
<h3 id="req-{item.id}-h">
|
||||
{#if item.task}
|
||||
<a href="/projects/worldwide-todo/{item.task.slug}">{item.task.title}</a>
|
||||
{:else}
|
||||
{String(item.payload.title ?? 'New task')}
|
||||
{/if}
|
||||
</h3>
|
||||
<span class="age">#{item.id} · {relativeTime(item.createdAt, now) || 'just now'}</span
|
||||
>
|
||||
</header>
|
||||
{#if item.stale}
|
||||
<p class="warn">
|
||||
<TriangleAlert size={14} aria-hidden="true" />
|
||||
{item.taskDeleted
|
||||
? 'Stale: the task was removed since this request was filed.'
|
||||
: 'Stale: the task changed since this request was filed. The diff below is against the current version.'}
|
||||
</p>
|
||||
{/if}
|
||||
{#if item.similar.length}
|
||||
<p class="dupe">
|
||||
<Copy size={14} aria-hidden="true" /> Similar:
|
||||
{#each item.similar as s, j (s.slug)}{j ? ', ' : ''}<a
|
||||
href="/projects/worldwide-todo/{s.slug}">{s.title}</a
|
||||
>
|
||||
({Math.round(s.similarity * 100)}%){/each}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if diff(item).length}
|
||||
<table class="diff">
|
||||
<caption class="sr-only">Changes</caption>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Field</th><th scope="col">Old</th><th scope="col">New</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each diff(item) as row (row.key)}
|
||||
<tr>
|
||||
<th scope="row">{FIELD_LABELS[row.key]}</th>
|
||||
<td
|
||||
>{#if row.old !== '—'}<del><span class="sr-only">Old: </span>{row.old}</del
|
||||
>{:else}—{/if}</td
|
||||
>
|
||||
<td
|
||||
>{#if row.new !== '—'}<ins><span class="sr-only">New: </span>{row.new}</ins
|
||||
>{:else}—{/if}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<dl class="meta">
|
||||
<dt>Reason</dt>
|
||||
<dd class="reason">{item.reason}</dd>
|
||||
{#if item.displayName}<dt>From</dt>
|
||||
<dd>{item.displayName}</dd>{/if}
|
||||
{#if sources(item).length}
|
||||
<dt>Sources</dt>
|
||||
<dd class="srcs">
|
||||
{#each sources(item) as url (url)}
|
||||
<a href={url} target="_blank" rel="noopener nofollow noreferrer">{host(url)}</a>
|
||||
{/each}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if item.ownerNote}<dt>Note</dt>
|
||||
<dd>{item.ownerNote}</dd>{/if}
|
||||
</dl>
|
||||
|
||||
{#if item.status === 'pending'}
|
||||
{#if form?.message && form.id === item.id}
|
||||
<p class="error" role="alert">{form.message}</p>
|
||||
{/if}
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
busy = item.id;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
busy = null;
|
||||
tweaking = null;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
{#if tweaking === item.id}
|
||||
<div class="tweak">
|
||||
<TaskFieldsEditor
|
||||
values={tweakValues(item)}
|
||||
categories={data.categories}
|
||||
id="t{item.id}"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<label class="note">
|
||||
<span>Note (optional, shown on the public request page)</span>
|
||||
<textarea name="note" rows="2" maxlength="1000"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
{#if tweaking === item.id}
|
||||
<button
|
||||
type="submit"
|
||||
formaction="?/tweak"
|
||||
class="primary"
|
||||
disabled={busy === item.id}
|
||||
><Check size={15} aria-hidden="true" />Apply tweaks & approve</button
|
||||
>
|
||||
<button type="button" onclick={() => (tweaking = null)}>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
type="submit"
|
||||
formaction="?/approve"
|
||||
data-action="approve"
|
||||
class="primary"
|
||||
disabled={busy === item.id}
|
||||
><Check size={15} aria-hidden="true" />Approve <kbd>a</kbd></button
|
||||
>
|
||||
{#if item.kind !== 'delete'}
|
||||
<button type="button" onclick={() => (tweaking = item.id)}
|
||||
><PencilLine size={15} aria-hidden="true" />Edit & approve
|
||||
<kbd>e</kbd></button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
formaction="?/reject"
|
||||
data-action="reject"
|
||||
class="danger"
|
||||
formnovalidate
|
||||
disabled={busy === item.id}
|
||||
><X size={15} aria-hidden="true" />Reject <kbd>r</kbd></button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{:else if item.decidedAt}
|
||||
<p class="decided">
|
||||
{item.status === 'approved' ? 'Approved' : 'Rejected'}
|
||||
{relativeTime(item.decidedAt, now) || 'just now'}
|
||||
</p>
|
||||
{/if}
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.queue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h2 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.keys {
|
||||
font-size: 0.78rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
kbd {
|
||||
display: inline-block;
|
||||
min-width: 1.2rem;
|
||||
padding: 0 0.3rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 0.3rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tabs a {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.83rem;
|
||||
color: var(--site-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tabs a:hover,
|
||||
.tabs a:focus-visible {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.tabs a.on {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.n {
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.flash {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--site-text);
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--site-surface);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.card.sel,
|
||||
.card:focus-visible {
|
||||
border-color: var(--site-text);
|
||||
box-shadow: inset 3px 0 0 var(--site-text);
|
||||
outline: none;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h3 {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
h3 a {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.kind {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
color: #fff;
|
||||
}
|
||||
.kind.add {
|
||||
background: #15803d;
|
||||
}
|
||||
.kind.edit {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.kind.delete {
|
||||
background: #b91c1c;
|
||||
}
|
||||
.age,
|
||||
.decided {
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.warn,
|
||||
.dupe {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.warn {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px dashed #d97706;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.dupe a {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.diff {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.diff th,
|
||||
.diff td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-top: 1px solid var(--site-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.diff thead th {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
border-top: 0;
|
||||
}
|
||||
.diff tbody th {
|
||||
width: 7rem;
|
||||
font-weight: 550;
|
||||
}
|
||||
del {
|
||||
background: color-mix(in srgb, #dc2626 16%, transparent);
|
||||
text-decoration-color: #dc2626;
|
||||
}
|
||||
ins {
|
||||
background: color-mix(in srgb, #16a34a 18%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.3rem 0.9rem;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
.meta dt {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.reason {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.srcs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.75rem;
|
||||
}
|
||||
.srcs a {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 550;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.tweak {
|
||||
padding: 0.8rem;
|
||||
border: 1px dashed var(--site-muted);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.note {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.note textarea {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
font-size: 0.84rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
.actions button:hover,
|
||||
.actions button:focus-visible {
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.actions .primary {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.actions .primary kbd {
|
||||
border-color: currentColor;
|
||||
}
|
||||
.actions .danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
.actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.actions button {
|
||||
transition: none;
|
||||
}
|
||||
.actions button:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
src/routes/projects/worldwide-todo/request/+page.server.ts
Normal file
59
src/routes/projects/worldwide-todo/request/+page.server.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { clientIp } from '#lib/server/worldtodo/limit.js';
|
||||
import { MAX_PENDING, submitRequest } from '#lib/server/worldtodo/requests.js';
|
||||
import { getTask, pendingTotal } from '#lib/server/worldtodo/store.js';
|
||||
import { turnstileConfigured, turnstileSiteKey } from '#lib/server/worldtodo/turnstile.js';
|
||||
import { formInput } from '#lib/server/worldtodo/validate.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const KINDS = ['add', 'edit', 'delete'] as const;
|
||||
type Kind = (typeof KINDS)[number];
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const raw = url.searchParams.get('kind') ?? 'add';
|
||||
const kind: Kind = (KINDS as readonly string[]).includes(raw) ? (raw as Kind) : 'add';
|
||||
let task = null;
|
||||
if (kind !== 'add') {
|
||||
const slug = url.searchParams.get('task') ?? '';
|
||||
const found = slug && slug.length <= 120 ? await getTask(slug) : null;
|
||||
if (!found) error(404, 'Not Found');
|
||||
task = found.task;
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
task,
|
||||
siteKey: turnstileSiteKey(),
|
||||
enabled: turnstileConfigured(),
|
||||
queueFull: (await pendingTotal()) >= MAX_PENDING,
|
||||
startedAt: Date.now(),
|
||||
seo: {
|
||||
title:
|
||||
kind === 'add'
|
||||
? 'Suggest a task · worldwide todo list'
|
||||
: `${kind === 'edit' ? 'Suggest an edit' : 'Request removal'} · worldwide todo list`,
|
||||
description: 'Suggest a change to the worldwide todo list. Every request is reviewed.',
|
||||
index: false
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, getClientAddress, setHeaders }) => {
|
||||
const form = await request.formData();
|
||||
const input = formInput(form);
|
||||
const result = await submitRequest({
|
||||
input,
|
||||
honeypot: form.get('website'),
|
||||
startedAt: form.get('started'),
|
||||
token: String(form.get('cf-turnstile-response') ?? ''),
|
||||
ip: clientIp(request, getClientAddress)
|
||||
});
|
||||
if (result.ok) redirect(303, `/projects/worldwide-todo/requests/${result.id}?new=1`);
|
||||
if (result.retryAfter) setHeaders({ 'retry-after': String(result.retryAfter) });
|
||||
return fail(result.status, {
|
||||
message: result.message,
|
||||
errors: result.errors ?? {},
|
||||
values: input
|
||||
});
|
||||
}
|
||||
};
|
||||
299
src/routes/projects/worldwide-todo/request/+page.svelte
Normal file
299
src/routes/projects/worldwide-todo/request/+page.svelte
Normal file
@@ -0,0 +1,299 @@
|
||||
<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>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { requestStatus } from '#lib/server/worldtodo/store.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Public request status: kind, status, dates and the owner's note once decided. Never the
|
||||
// reason, the payload or the display name.
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
if (!/^\d{1,12}$/.test(params.id)) error(404, 'Not Found');
|
||||
const req = await requestStatus(Number(params.id));
|
||||
if (!req) error(404, 'Not Found');
|
||||
return {
|
||||
req,
|
||||
justSent: url.searchParams.has('new'),
|
||||
seo: {
|
||||
title: `Request #${req.id} · worldwide todo list`,
|
||||
description: 'Request status',
|
||||
index: false
|
||||
}
|
||||
};
|
||||
};
|
||||
116
src/routes/projects/worldwide-todo/requests/[id]/+page.svelte
Normal file
116
src/routes/projects/worldwide-todo/requests/[id]/+page.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
||||
import CircleX from 'lucide-svelte/icons/circle-x';
|
||||
import Hourglass from 'lucide-svelte/icons/hourglass';
|
||||
|
||||
let { data } = $props();
|
||||
const r = $derived(data.req);
|
||||
const kind = { add: 'New task', edit: 'Edit', delete: 'Removal' } as const;
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Amsterdam',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="wrap" aria-labelledby="req-h">
|
||||
{#if data.justSent && r.status === 'pending'}
|
||||
<p class="thanks" role="status">Thanks, your request is waiting for review.</p>
|
||||
{/if}
|
||||
<h2 id="req-h">Request #{r.id}</h2>
|
||||
<div class="card">
|
||||
<span class="state {r.status}">
|
||||
{#if r.status === 'approved'}<CircleCheck size={18} aria-hidden="true" />Approved
|
||||
{:else if r.status === 'rejected'}<CircleX size={18} aria-hidden="true" />Not accepted
|
||||
{:else}<Hourglass size={18} aria-hidden="true" />Waiting for review{/if}
|
||||
</span>
|
||||
<dl>
|
||||
<dt>Type</dt>
|
||||
<dd>{kind[r.kind]}</dd>
|
||||
{#if r.task}
|
||||
<dt>Task</dt>
|
||||
<dd><a href="/projects/worldwide-todo/{r.task.slug}">{r.task.title}</a></dd>
|
||||
{/if}
|
||||
<dt>Sent</dt>
|
||||
<dd><time datetime={r.createdAt}>{fmt(r.createdAt)}</time></dd>
|
||||
{#if r.decidedAt}
|
||||
<dt>Decided</dt>
|
||||
<dd><time datetime={r.decidedAt}>{fmt(r.decidedAt)}</time></dd>
|
||||
{/if}
|
||||
{#if r.ownerNote}
|
||||
<dt>Note</dt>
|
||||
<dd>{r.ownerNote}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
<p class="help">
|
||||
Bookmark this page to check back. <a href="/projects/worldwide-todo">Back to the list</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
max-width: 36rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.thanks {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--site-text);
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 550;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--site-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.approved :global(svg) {
|
||||
color: #16a34a;
|
||||
}
|
||||
.rejected :global(svg) {
|
||||
color: #dc2626;
|
||||
}
|
||||
.pending :global(svg) {
|
||||
color: #d97706;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.35rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
dd a {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.help {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.help a {
|
||||
color: var(--site-text);
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import { COURSES } from '#lib/language-learner/courses/index.js';
|
||||
import { gameTrackers } from '#lib/projects.js';
|
||||
import { and, eq, isNull, ne } from 'drizzle-orm';
|
||||
import { getVisibility } from '#lib/server/movies/site.js';
|
||||
import { sitemapSlugs } from '#lib/server/worldtodo/store.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const staticPaths = [
|
||||
@@ -49,6 +50,7 @@ const staticPaths = [
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
'/projects/game-tracker/pokemon/locations',
|
||||
'/projects/worldwide-todo',
|
||||
'/projects/animaldex',
|
||||
'/projects/derby',
|
||||
'/projects/spotify-beatmap-finder',
|
||||
@@ -70,7 +72,7 @@ function escapeXml(value: string): string {
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const [articles, lists, books, moviesVisibility] = await Promise.all([
|
||||
const [articles, lists, books, moviesVisibility, worldtodo] = await Promise.all([
|
||||
db
|
||||
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
|
||||
.from(blogArticles)
|
||||
@@ -81,7 +83,8 @@ export const GET: RequestHandler = async () => {
|
||||
.from(bookWorks)
|
||||
// Articles are links to other sites; their pages here are thin.
|
||||
.where(and(ne(bookWorks.kind, 'article'), isNull(bookWorks.archivedAt))),
|
||||
getVisibility()
|
||||
getVisibility(),
|
||||
sitemapSlugs().catch(() => [])
|
||||
]);
|
||||
const entries: { path: string; lastmod?: string }[] = [
|
||||
...staticPaths
|
||||
@@ -96,6 +99,10 @@ export const GET: RequestHandler = async () => {
|
||||
...books.map((book) => ({
|
||||
path: `/trackers/books/${book.id}`,
|
||||
lastmod: book.updatedAt.toISOString()
|
||||
})),
|
||||
...worldtodo.map((task) => ({
|
||||
path: `/projects/worldwide-todo/${encodeURIComponent(task.slug)}`,
|
||||
lastmod: task.updatedAt
|
||||
}))
|
||||
];
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
||||
import Sparkline from '#lib/components/Sparkline.svelte';
|
||||
import { visibleTrackers } from '#lib/projects.js';
|
||||
import { projects, visibleTrackers } from '#lib/projects.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -337,7 +337,9 @@
|
||||
.flatMap((t) => [t, ...(t.children ?? [])])
|
||||
.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })),
|
||||
...extraHits.map((t) => ({ ...t, kind: 'tracker' })),
|
||||
...statsHits.map((t) => ({ ...t, kind: 'stats' }))
|
||||
...statsHits.map((t) => ({ ...t, kind: 'stats' })),
|
||||
...projects.map((p) => ({ name: p.name, href: p.href, kind: 'project' })),
|
||||
{ name: 'Worldwide todo queue', href: '/projects/worldwide-todo/queue', kind: 'project' }
|
||||
]);
|
||||
const hits = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -569,6 +571,9 @@
|
||||
>
|
||||
{/if}
|
||||
<a class="chip" href="/stats" title="Economy, energy and Gouda statistics">stats</a>
|
||||
<a class="chip" href="/projects/worldwide-todo/queue" title="Worldwide todo list review queue"
|
||||
>todo queue</a
|
||||
>
|
||||
{#if data.weight}<a class="chip" href="/trackers/health#body" title={data.weight.date}
|
||||
>{data.weight.kg} kg</a
|
||||
>{/if}
|
||||
|
||||
Reference in New Issue
Block a user