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:
1274
data/worldtodo-seed.json
Normal file
1274
data/worldtodo-seed.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,8 @@ export default defineConfig({
|
|||||||
'dataholders',
|
'dataholders',
|
||||||
'stats',
|
'stats',
|
||||||
'ai',
|
'ai',
|
||||||
'movies'
|
'movies',
|
||||||
|
'worldtodo'
|
||||||
],
|
],
|
||||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
95
drizzle/20261026012608_worldtodo/migration.sql
Normal file
95
drizzle/20261026012608_worldtodo/migration.sql
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
CREATE SCHEMA "worldtodo";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TYPE "worldtodo"."req_kind" AS ENUM('add', 'edit', 'delete');--> statement-breakpoint
|
||||||
|
CREATE TYPE "worldtodo"."req_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint
|
||||||
|
CREATE TYPE "worldtodo"."scale" AS ENUM('planet', 'region', 'country', 'city', 'street');--> statement-breakpoint
|
||||||
|
CREATE TYPE "worldtodo"."status" AS ENUM('open', 'in_progress', 'done');--> statement-breakpoint
|
||||||
|
CREATE TABLE "worldtodo"."categories" (
|
||||||
|
"slug" text PRIMARY KEY,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"icon" text NOT NULL,
|
||||||
|
"colour" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "worldtodo"."change_requests" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "worldtodo"."change_requests_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"kind" "worldtodo"."req_kind" NOT NULL,
|
||||||
|
"task_id" bigint,
|
||||||
|
"payload" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"applied" jsonb,
|
||||||
|
"task_updated_at" timestamp with time zone,
|
||||||
|
"reason" text NOT NULL,
|
||||||
|
"display_name" text,
|
||||||
|
"status" "worldtodo"."req_status" DEFAULT 'pending'::"worldtodo"."req_status" NOT NULL,
|
||||||
|
"owner_note" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"decided_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "change_requests_reason_check" CHECK (char_length("reason") BETWEEN 10 AND 1000),
|
||||||
|
CONSTRAINT "change_requests_display_name_check" CHECK (char_length("display_name") <= 40),
|
||||||
|
CONSTRAINT "change_requests_task_check" CHECK ((("kind" = 'add') = ("task_id" IS NULL)))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "worldtodo"."tasks" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "worldtodo"."tasks_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"scale" "worldtodo"."scale" NOT NULL,
|
||||||
|
"category" text NOT NULL,
|
||||||
|
"location_name" text NOT NULL,
|
||||||
|
"country_code" text,
|
||||||
|
"lat" double precision,
|
||||||
|
"lon" double precision,
|
||||||
|
"status" "worldtodo"."status" DEFAULT 'open'::"worldtodo"."status" NOT NULL,
|
||||||
|
"source_urls" text[] NOT NULL,
|
||||||
|
"source_date" text,
|
||||||
|
"confidence" text DEFAULT 'high' NOT NULL,
|
||||||
|
"verification_note" text,
|
||||||
|
"as_of" date NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"search" tsvector GENERATED ALWAYS AS (to_tsvector('simple', title || ' ' || description || ' ' || location_name)) STORED,
|
||||||
|
CONSTRAINT "tasks_title_check" CHECK (char_length("title") BETWEEN 5 AND 140),
|
||||||
|
CONSTRAINT "tasks_description_check" CHECK (char_length("description") <= 2000),
|
||||||
|
CONSTRAINT "tasks_lat_check" CHECK ("lat" BETWEEN -90 AND 90),
|
||||||
|
CONSTRAINT "tasks_lon_check" CHECK ("lon" BETWEEN -180 AND 180),
|
||||||
|
CONSTRAINT "tasks_sources_check" CHECK (cardinality("source_urls") >= 1),
|
||||||
|
CONSTRAINT "tasks_confidence_check" CHECK ("confidence" IN ('high','medium'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "change_requests_status_idx" ON "worldtodo"."change_requests" ("status","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "change_requests_task_idx" ON "worldtodo"."change_requests" ("task_id","status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "tasks_slug_key" ON "worldtodo"."tasks" ("slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_search_idx" ON "worldtodo"."tasks" USING gin ("search");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_scale_status_idx" ON "worldtodo"."tasks" ("scale","status") WHERE deleted_at IS NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "worldtodo"."change_requests" ADD CONSTRAINT "change_requests_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "worldtodo"."tasks"("id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "worldtodo"."tasks" ADD CONSTRAINT "tasks_category_categories_slug_fkey" FOREIGN KEY ("category") REFERENCES "worldtodo"."categories"("slug");
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- updated_at trigger on tasks, same as the other core tables.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON worldtodo.tasks
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Grants: app role read/write only (like ai and movies; no personal_ro: change requests may
|
||||||
|
-- carry a display name). Prod only, where migrations run as `personal`. Rows are seeded by
|
||||||
|
-- scripts/seed-worldtodo.ts on every deploy.
|
||||||
|
REVOKE ALL ON SCHEMA worldtodo FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA worldtodo TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA worldtodo TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA worldtodo TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA worldtodo
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA worldtodo
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
40822
drizzle/20261026012608_worldtodo/snapshot.json
Normal file
40822
drizzle/20261026012608_worldtodo/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
237
scripts/build-worldtodo-seed.py
Normal file
237
scripts/build-worldtodo-seed.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import json, re
|
||||||
|
AS_OF = "2026-10-26"
|
||||||
|
T = []
|
||||||
|
def t(scale, category, title, description, location, sources, lat=None, lon=None, status="open", cc=None, source_date=None, confidence="high", note=None):
|
||||||
|
slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')[:70].rstrip('-')
|
||||||
|
d = dict(slug=slug, title=title, description=description, scale=scale, category=category,
|
||||||
|
location_name=location, country_code=cc, lat=lat, lon=lon, status=status,
|
||||||
|
source_urls=sources, source_date=source_date, as_of=AS_OF, confidence=confidence)
|
||||||
|
if note: d["verification_note"] = note
|
||||||
|
T.append(d)
|
||||||
|
|
||||||
|
# ---------------- PLANET ----------------
|
||||||
|
P="planet"; W="World"
|
||||||
|
t(P,"health","Interrupt the last wild poliovirus transmission in Afghanistan and Pakistan",
|
||||||
|
"Wild poliovirus type 1 is still endemic only in Afghanistan and Pakistan; GPEI/WHO counted 48 WPV1 cases in 2026 as of mid-October (42 Afghanistan, 6 Pakistan). Reach every missed child with vaccination so polio can be declared eradicated.",
|
||||||
|
"Afghanistan & Pakistan (global eradication)", ["https://polioeradication.org/about-polio/polio-this-week/","https://www.emro.who.int/polio-eradication/home/"], 31.0, 68.0, source_date="2026-10-18")
|
||||||
|
t(P,"health","Eradicate Guinea worm disease: stop the last human and animal infections",
|
||||||
|
"Only 10 human Guinea worm cases were reported worldwide in 2025 (Chad 4, Ethiopia 4, South Sudan 2), but 684 animal infections keep transmission alive. Finish containment, water filtration and animal surveillance in the six remaining countries.",
|
||||||
|
"Chad, Ethiopia, South Sudan, Cameroon, Mali, Angola", ["https://www.cartercenter.org/news/guinea-worm-announcement/","https://www.cartercenter.org/programs/guinea-worm/"], source_date="2026")
|
||||||
|
t(P,"environment","Agree and adopt a binding global treaty to end plastic pollution",
|
||||||
|
"UN plastics-treaty talks (INC) have not produced a treaty; INC-5.3 in Feb 2026 only elected a new chair, and the next substantive session, INC-5.4, is planned for 13–24 March 2027. Resolve the open disputes (production limits, chemicals of concern, decision rules) and adopt the treaty.",
|
||||||
|
W, ["https://www.unep.org/en/inc-plastic-pollution","https://www.france24.com/en/live-news/20261001-plastic-treaty-talks-chair-sees-deal-possible-in-march"], source_date="2026-10-01")
|
||||||
|
t(P,"environment","Designate the first marine protected areas under the High Seas Treaty",
|
||||||
|
"The BBNJ (High Seas) Agreement entered into force on 17 January 2026; its first COP is set for 11–22 January 2027 in New York. Set up the scientific body and designate the first high-seas protected areas so the treaty actually protects ocean life beyond national borders.",
|
||||||
|
"High seas (areas beyond national jurisdiction)", ["https://www.un.org/bbnjagreement/en","https://www.eli.org/vibrant-environment-blog/charting-new-waters-high-seas-treaty-after-two-months"], source_date="2026")
|
||||||
|
t(P,"food","Get food to the 645 million people who are chronically hungry",
|
||||||
|
"The UN SOFI 2026 report estimates 645 million people (7.8% of humanity) faced hunger in 2025. Progress is uneven, with hunger still rising in parts of Africa. Zero Hunger (SDG 2) by 2030 is off track.",
|
||||||
|
W, ["https://news.un.org/en/story/2026/07/1167987","https://www.fao.org/publications/fao-flagship-publications/the-state-of-food-security-and-nutrition-in-the-world/2026/en"], source_date="2026-07")
|
||||||
|
t(P,"energy","Connect the 655 million people who still have no electricity",
|
||||||
|
"Tracking SDG7 2026: 655 million people still lack electricity, more than 560 million of them in Sub-Saharan Africa. Electrification has to roughly triple in pace to reach universal access by 2030.",
|
||||||
|
W, ["https://www.esmap.org/news/tracking-sdg7-655-million-people-still-without-electricity-underscore-urgent-need-deliver","https://www.iea.org/news/655-million-people-still-living-without-electricity-underscore-urgent-need-to-deliver-on-universal-energy-access-target"], source_date="2026-06")
|
||||||
|
t(P,"health","Give the 2 billion people cooking on polluting fuels access to clean cooking",
|
||||||
|
"About 2 billion people still cook with polluting fuels such as wood, charcoal and kerosene, which causes deadly household air pollution. On current trends 1.8 billion will still do so in 2030. In Sub-Saharan Africa alone, 970 million people lack clean cooking.",
|
||||||
|
W, ["https://www.esmap.org/news/tracking-sdg7-655-million-people-still-without-electricity-underscore-urgent-need-deliver","https://unstats.un.org/sdgs/report/2026/Goal-07/"], source_date="2026-06")
|
||||||
|
t(P,"health","Vaccinate the 13.5 million 'zero-dose' children who got no routine vaccines",
|
||||||
|
"WHO/UNICEF estimates (released July 2026) show that 13.5 million infants received no DTP vaccine at all in 2025. That is still 674,000 more than the 2019 baseline. Reach them, mostly in conflict-affected and fragile settings.",
|
||||||
|
W, ["https://www.who.int/news/item/15-07-2026-global-childhood-immunization-coverage-inches-forward-despite-conflict-and-hesitancy---unicef--who"], source_date="2026-07-15")
|
||||||
|
t(P,"health","Cut malaria's 282 million cases a year and contain antimalarial drug resistance",
|
||||||
|
"The WHO World Malaria Report 2025 estimates 282 million cases and 610,000 deaths in 2024, both rising, with about 95% in Africa. Partial resistance to artemisinin threatens current treatments. Scale up vaccines, nets and new drugs.",
|
||||||
|
W, ["https://www.who.int/teams/global-malaria-programme/reports/world-malaria-report-2025/","https://www.who.int/en/news-room/fact-sheets/detail/malaria"], source_date="2025-12")
|
||||||
|
t(P,"water","Provide safely managed drinking water to the 2.1 billion people without it",
|
||||||
|
"One in four people worldwide (2.1 billion) still lack safely managed drinking water, and 106 million drink untreated surface water straight from rivers and lakes (WHO/UNICEF JMP).",
|
||||||
|
W, ["https://www.who.int/news/item/26-08-2025-1-in-4-people-globally-still-lack-access-to-safe-drinking-water---who--unicef","https://data.unicef.org/resources/jmp-report-2025/"], source_date="2025-08-26")
|
||||||
|
t(P,"climate","Close the gap on the Global Methane Pledge (-30% by 2030)",
|
||||||
|
"UNEP's Global Methane Status Report says methane emissions are still rising. Even if fully implemented, current national plans would deliver only about an 8% cut by 2030, not the pledged 30%. Fix leaks in oil and gas, landfills and agriculture.",
|
||||||
|
W, ["https://www.unep.org/en/news-and-stories/press-release/ministers-urge-decisive-methane-action-global-report-shows-progress"], source_date="2025-11")
|
||||||
|
t(P,"health","Cut deaths from antimicrobial resistance by 10% by 2030",
|
||||||
|
"In the 2024 UN political declaration, countries committed to cutting deaths associated with bacterial AMR by 10% by 2030, from a 2019 baseline of 4.95 million associated deaths. Deliver the surveillance, stewardship, antibiotic access and funding needed to get there.",
|
||||||
|
W, ["https://www.who.int/news/item/26-09-2024-world-leaders-commit-to-decisive-action-on-antimicrobial-resistance","https://press.un.org/en/2024/ga12642.doc.htm"], source_date="2024-09-26")
|
||||||
|
t(P,"infrastructure","Stop low Earth orbit filling up with space debris",
|
||||||
|
"ESA's Space Environment Report 2026 counts roughly 1.5 million objects of 1–10 cm in orbit, and its orbital Health Index got about ten times worse in one year. Enforce end-of-life deorbiting, prevent fragmentations and fund active debris removal.",
|
||||||
|
"Earth orbit", ["https://www.esa.int/Space_Safety/Space_Debris/ESA_Space_Environment_Report_2026"], source_date="2026-09")
|
||||||
|
|
||||||
|
# ---------------- REGION ----------------
|
||||||
|
R="region"
|
||||||
|
t(R,"climate","Scale up the Great Green Wall: from 1.66M toward 100M hectares restored across the Sahel",
|
||||||
|
"As of end-2025 the Great Green Wall Observatory reports 1.66 million hectares under restoration, against a 100-million-hectare target for 2030. A new US$78M programme for 2026 aims to restore 270,000+ hectares in nine countries. Finance and deliver the rest.",
|
||||||
|
"Sahel (Senegal to Djibouti)", ["https://www.unep.org/en/news-and-stories/press-release/new-us78-million-initiative-restore-land-improve-livelihoods-across","https://www.au.int/en/pressreleases/20260824/ambition-investment-calls-scaled-finance-great-green-wall"], 15.0, 5.0, source_date="2026-08")
|
||||||
|
t(R,"health","End the multi-country cholera outbreaks across Africa",
|
||||||
|
"WHO reported cholera cases in 16 African countries by mid-2026, led by DR Congo, South Sudan and Mozambique. Africa CDC runs a continental cholera response plan. Fund WASH, oral vaccines and case management to stop it.",
|
||||||
|
"Africa (DRC, South Sudan, Mozambique, Malawi, Zambia, Zimbabwe, Namibia…)", ["https://africacdc.org/news-item/president-hakainde-hichilema-au-cholera-champion-joins-partners-to-unveil-africas-new-continental-cholera-plan/","https://www.afro.who.int/news/southern-africa-faces-acute-surge-cholera-due-floods-displacement"], -5.0, 25.0, source_date="2026")
|
||||||
|
t(R,"environment","Drive Amazon deforestation from 3,335 km² a year to zero",
|
||||||
|
"INPE's PRODES estimate for Aug 2025–Jul 2026 is 3,335 km² cleared in Brazil's Legal Amazon, the lowest since 1988 but still more than 3,000 km² of forest lost a year. Hold enforcement and reach the zero-deforestation-by-2030 goal.",
|
||||||
|
"Amazon basin (Brazil's Legal Amazon)", ["https://www.gov.br/inpe/pt-br/assuntos/ultimas-noticias/brasil-alcanca-menores-taxas-de-desmatamento-da-serie-historica-na-amazonia-e-no-cerrado","https://terrabrasilis.dpi.inpe.br/app/dashboard/deforestation/biomes/legal_amazon/rates"], -5.0, -60.0, source_date="2026-10")
|
||||||
|
t(R,"environment","Control crown-of-thorns starfish outbreaks so the Great Barrier Reef can recover",
|
||||||
|
"The AIMS 2025/26 survey found hard coral cover recovering in the northern and central Great Barrier Reef but slipping in the south. The reef is still under pressure from cyclones, flooding and crown-of-thorns starfish. Keep culling and cut runoff.",
|
||||||
|
"Great Barrier Reef, Australia", ["https://www.aims.gov.au/monitoring-great-barrier-reef/gbr-condition-summary-2025-26","https://www.gbrmpa.gov.au/learn/reef-health/reef-health-updates"], -18.3, 147.7, cc="AU", source_date="2026")
|
||||||
|
t(R,"environment","Recover the WWII munitions dumped in the Baltic and North Seas",
|
||||||
|
"Germany's Baltic pilot recovered about 18 tonnes of sea-dumped munitions in 2024–25, a tiny fraction of what lies on the seabed corroding and leaking toxins. Next steps are an expanded Baltic phase, North Sea clearance and an offshore disposal platform due around end-2028.",
|
||||||
|
"Baltic Sea & North Sea", ["https://www.bundesumweltministerium.de/pressemitteilung/probephase-zur-munitionsbergung-erfolgreich-abgeschlossen","https://www.bundesumweltministerium.de/en/topics/marine-conservation/unexploded-munitions-in-the-sea"], 54.5, 11.0, source_date="2026")
|
||||||
|
t(R,"humanitarian","Fully fund food aid for Rohingya refugees in Cox's Bazar",
|
||||||
|
"Since April 2026 WFP has cut many of the roughly 1 million Rohingya refugees in Bangladesh to a tiered US$7–10 monthly food voucher, down from US$12, for lack of funding. Refugees report hardship and more negative coping. Restore full rations and fund the response.",
|
||||||
|
"Cox's Bazar, Bangladesh / Myanmar", ["https://www.wfp.org/news/wfp-introduces-needs-based-food-assistance-approach-rohingya-refugees-bangladesh","https://apnews.com/article/rohingya-bangladesh-aid-ration-cuts-wfp-8349d38f8f8b21c96e70b5e805468fd1"], 21.2, 92.15, source_date="2026")
|
||||||
|
t(R,"humanitarian","Clear Gaza's ~61 million tonnes of war rubble",
|
||||||
|
"UNDP estimates Gaza holds about 61 million tonnes of debris. Clearing most of it could take around seven years, and only with unimpeded access, heavy machinery, fuel and US$110M in up-front funding.",
|
||||||
|
"Gaza Strip", ["https://www.undp.org/stories/clearing-most-rubble-gaza-strip-possible-seven-years-under-right-conditions","https://www.reuters.com/world/middle-east/palestinians-use-gaza-rubble-restore-streets-us-rebuilding-plan-stalls-2026-04-27/"], 31.45, 34.4, source_date="2025-11-20")
|
||||||
|
t(R,"humanitarian","Stop migrants drowning on the Central Mediterranean route",
|
||||||
|
"IOM recorded 996 people dead or missing on the Central Mediterranean route by 15 Sept 2026, up from 844 in the same period of 2025, even though arrivals there fell by 55%. Restore search-and-rescue capacity and open safe routes.",
|
||||||
|
"Central Mediterranean (Libya/Tunisia – Italy/Malta)", ["https://www.iom.int/news/new-iom-data-migrant-arrivals-europe-fall-39-2026-while-deaths-continue-rise","https://missingmigrants.iom.int/data"], 35.0, 14.0, source_date="2026-09")
|
||||||
|
t(R,"humanitarian","Fund the response for 8.2 million people in need in the Lake Chad Basin",
|
||||||
|
"OCHA (9 Sept 2026): 8.2 million people need humanitarian help across Cameroon, Chad, Niger and Nigeria around Lake Chad, with more than 3.5 million forcibly displaced.",
|
||||||
|
"Lake Chad Basin (Cameroon, Chad, Niger, Nigeria)", ["https://reliefweb.int/report/cameroon/lake-chad-basin-humanitarian-overview-2026-9-september-2026"], 13.0, 14.0, source_date="2026-09-09")
|
||||||
|
t(R,"climate","Build end-to-end glacial-lake-outburst-flood warning systems in the Himalaya",
|
||||||
|
"Glacier melt across the Hindu Kush Himalaya is swelling glacial lakes. India runs only a pilot GLOF early-warning system (Sissu Lake) and has no end-to-end national system commissioned. ICIMOD is preparing a 2027–2030 regional programme on early warning.",
|
||||||
|
"Hindu Kush Himalaya (Afghanistan to Myanmar)", ["https://www.icimod.org/event/cryosphere-water/hkh-drr-hub-2026/","https://timesofindia.indiatimes.com/city/dehradun/ndma-moves-to-scale-up-glof-warning-system-after-sissu-pilot-review/articleshow/130415374.cms"], 30.0, 82.0, source_date="2026")
|
||||||
|
t(R,"humanitarian","Get water and food to drought-hit communities in Somalia, Kenya and Ethiopia",
|
||||||
|
"Failed Oct–Dec rains triggered severe drought across Somalia, northern Kenya and south-eastern Ethiopia. About 5 million people are affected in Somalia alone, and Oxfam reports water prices up 2,000% in the worst-hit areas.",
|
||||||
|
"Horn of Africa", ["https://www.unocha.org/publications/report/somalia/somalia-2025-2026-drought-emergency-situation-report-no-5","https://www.oxfam.org/en/press-releases/millions-people-across-somalia-kenya-and-ethiopia-facing-drought-crisis-cost-water"], 4.0, 43.0, source_date="2026-04")
|
||||||
|
t(R,"environment","Curb river sand mining that is pushing salt water into the Mekong Delta",
|
||||||
|
"A 2026 NTU Singapore study finds sand mining extracts 6–15× the river's natural sediment supply and drives saltwater intrusion further inland, on top of subsidence from groundwater pumping. Set and enforce sustainable extraction limits.",
|
||||||
|
"Mekong Delta (Vietnam/Cambodia)", ["https://www.ntu.edu.sg/nie/news-events/news/detail/sand-mining-is-destabilising-the-mekong-delta-far-more-than-previously-understood--nie--ntu-singapore-study-finds"], 10.0, 105.8, source_date="2026")
|
||||||
|
|
||||||
|
# ---------------- COUNTRY ----------------
|
||||||
|
C="country"
|
||||||
|
t(C,"humanitarian","Prevent famine in Sudan's Darfur and Kordofan",
|
||||||
|
"The IPC alert of 12 Oct 2026 warns of rapidly worsening conditions across Greater Darfur and Kordofan. Famine is highly likely in El Fasher, and access is severely restricted. Sudan's 2026 response plan asks US$2.9 billion and was badly underfunded.",
|
||||||
|
"Sudan", ["https://reliefweb.int/report/sudan/sudan-ipc-alert-new-evidence-reveals-rapidly-worsening-conditions-across-greater-darfur-and-kordofan-published-12-october-2026","https://fts.unocha.org/countries/212/summary/2026"], 13.6, 25.35, cc="SD", source_date="2026-10-12")
|
||||||
|
t(C,"safety","Demine Ukraine's ~132,000 km² of mine-contaminated land",
|
||||||
|
"As of Feb 2026, about 132,000 km² of Ukraine, nearly a quarter of the country, was potentially contaminated by mines and explosive remnants. Clearance teams return a few thousand hectares a month.",
|
||||||
|
"Ukraine", ["https://reliefweb.int/report/ukraine/landmines-use-contamination-and-civilian-harm-ukraine-factsheet-2026-enuk","https://demine.gov.ua/en"], 48.4, 31.2, cc="UA", source_date="2026-02")
|
||||||
|
t(C,"humanitarian","Help Haiti's 1.49 million displaced people return home safely",
|
||||||
|
"IOM counted 1,494,456 internally displaced people in Haiti (about 13% of the population) as of August 2026, driven by gang violence. Cholera also keeps recurring.",
|
||||||
|
"Haiti", ["https://reliefweb.int/report/haiti/iom-haiti-situation-report-august-2026"], 18.97, -72.29, cc="HT", source_date="2026-08")
|
||||||
|
t(C,"water","Get arsenic out of Bangladesh's drinking water",
|
||||||
|
"A 2025 survey estimates about 26 million Bangladeshis still drink groundwater above the WHO arsenic guideline (10 µg/L), about 15.5 million of them above the national 50 µg/L standard. Test wells and switch people to safe sources.",
|
||||||
|
"Bangladesh", ["https://www.medrxiv.org/content/10.64898/2026.10.06.26364882v1.full.pdf","https://www.bssnews.net/js-session/401178"], 23.7, 90.4, cc="BD", source_date="2026-10")
|
||||||
|
t(C,"water","Replace every lead drinking-water pipe in the United States",
|
||||||
|
"Under EPA's Lead and Copper Rule Improvements, most US water systems must start mandatory lead service line replacement by 1 Nov 2027 and finish by 31 Dec 2037.",
|
||||||
|
"United States", ["https://www.epa.gov/ground-water-and-drinking-water/planning-and-conducting-lead-service-line-replacement","https://www.federalregister.gov/documents/2024/10/30/2024-23549/national-primary-drinking-water-regulations-for-lead-and-copper-improvements-lcri"], 39.8, -98.6, cc="US", source_date="2026")
|
||||||
|
t(C,"water","Stop sewage spills into England's rivers and seas",
|
||||||
|
"Storm overflows in England still spilled 291,492 times in 2025, totalling around 1.8 million hours, even though that was down 35% on 2024 (Environment Agency).",
|
||||||
|
"England, UK", ["https://www.gov.uk/government/news/fewer-and-shorter-storm-overflow-spills-in-2025-new-monitoring-data-shows","https://commonslibrary.parliament.uk/research-briefings/cbp-10027/"], 52.5, -1.5, cc="GB", source_date="2026-03")
|
||||||
|
t(C,"infrastructure","Fix the Netherlands' electricity grid congestion",
|
||||||
|
"On 30 June 2026, Dutch regional grid operators had 15,498 requests to take power and 8,558 to feed it in waiting on queues because the grid is full (Netbeheer Nederland). Small consumers in congested areas have also been queued since 1 July 2026.",
|
||||||
|
"Netherlands", ["https://www.netbeheernederland.nl/artikelen/persbericht/bouw-versnelt-opnieuw-maar-de-vraag-loopt-nog-steeds-op","https://www.netbeheernederland.nl/sites/default/files/2026-10/stand_van_de_uitvoering_halfjaar_2026.pdf"], 52.2, 5.3, cc="NL", source_date="2026-10")
|
||||||
|
t(C,"housing","Close the Dutch housing shortage of 384,000 homes",
|
||||||
|
"The Primos forecast 2026 puts the Dutch statistical housing shortage at 384,000 homes (4.6% of stock). 213,000 extra homes would be needed immediately just to get down to the 2% target level.",
|
||||||
|
"Netherlands", ["https://www.tweedekamer.nl/kamerstukken/detail/detail?did=2026D34061&id=2026D34061"], 52.2, 5.3, cc="NL", source_date="2026-07")
|
||||||
|
t(C,"safety","Finish earthquake-strengthening the remaining ~8,500 homes in Groningen",
|
||||||
|
"The Nationaal Coördinator Groningen's 2026 plan still lists 8,583 addresses to strengthen against gas-extraction earthquakes, 1,278 of them heavy or demolish-and-rebuild cases.",
|
||||||
|
"Groningen, Netherlands", ["https://www.nationaalcoordinatorgroningen.nl/site/binaries/site-content/collections/documents/2025/12/jaarplan-ncg-2026/jaarplan-ncg-2026.pdf","https://www.oogtv.nl/2026/02/ncg-blij-over-aantal-veilig-beoordeelde-huizen-maar-versterking-of-nieuwbouw-begint-bij-13-procent-te-laat/"], 53.22, 6.57, cc="NL", source_date="2026")
|
||||||
|
t(C,"governance","Finish the last benefits-scandal (toeslagenaffaire) reviews",
|
||||||
|
"As of 25 Sept 2026, 103 parents were still waiting on their full review (integrale beoordeling) by the UHT: 90 in progress and 13 not yet started. Close them out fairly.",
|
||||||
|
"Netherlands", ["https://herstel.toeslagen.nl/dashboard-kinderopvangtoeslag/"], 52.08, 4.31, cc="NL", source_date="2026-09-25", confidence="medium", note="Small remaining backlog; may be finished soon, so re-check the dashboard.")
|
||||||
|
t(C,"infrastructure","Fund the renewal of ageing Dutch national bridges, tunnels and locks",
|
||||||
|
"Rijkswaterstaat manages about 5,500 concrete bridges and 88 lock complexes. Its maintenance and renewal job is bigger than its budget, with a reported shortfall of about €34.5bn for 2024–2038, and an Oct 2026 Kamerbrief re-prioritises the work.",
|
||||||
|
"Netherlands", ["https://www.rijkswaterstaat.nl/nieuws/archief/2026/10/kamerbrief-herprioritering-onderhoud-en-vernieuwing-infrastructuur","https://zoek.officielebekendmakingen.nl/ah-tk-20252026-914.html"], 52.2, 5.3, cc="NL", source_date="2026-10")
|
||||||
|
t(C,"infrastructure","Speed up Germany's motorway bridge repairs (target 400 a year, achieving ~170)",
|
||||||
|
"Germany aims to modernise 4,000 motorway bridge sections in ten years, about 400 a year. Only 157 were finished in 2025 and about 170 are expected in 2026.",
|
||||||
|
"Germany", ["https://baubote.de/tiefbau-infrastruktur/brueckensanierung-autobahn-zahlen-2025-2026","https://www.handelsblatt.com/mobilitaet/background-verkehr-und-smart-mobility/autobahnen-sind-deutschlands-bruecken-halb-verfallen-oder-halb-saniert/100263521.html"], 51.2, 10.4, cc="DE", source_date="2026")
|
||||||
|
|
||||||
|
# ---------------- CITY ----------------
|
||||||
|
Y="city"
|
||||||
|
t(Y,"infrastructure","Lower Gouda's inner-city groundwater level to stop subsidence damage",
|
||||||
|
"Gouda and Rijnland are working on 'Gouda Stevige Stad': lowering the water level in the lowest, subsiding parts of the historic centre by 25 cm. Sewers, water-level barriers and a pumping station must be ready first, and the first level reduction is expected in 2027.",
|
||||||
|
"Gouda (binnenstad), NL", ["https://www.gouda.nl/gouda-werkt-aan-de-stad/gouda-stevige-stad/","https://gouda.bestuurlijkeinformatie.nl/Document/View/ee2f89a0-8e2a-4176-b8a8-1e7caf2cd15c"], 52.0115, 4.7105, cc="NL", status="in_progress", source_date="2026")
|
||||||
|
t(Y,"infrastructure","Replace Amsterdam's failing historic quay walls",
|
||||||
|
"Amsterdam has replaced about 3.5 km of quay wall since 2021. In 2026 it is working on 14 quay projects (about 2,150 m) under its Bridges & Quay Walls programme. Hundreds of kilometres of old quays still need assessing and renewing.",
|
||||||
|
"Amsterdam, NL", ["https://aandegrachten.amsterdam/hier-werken-we-in-2026-aan-bruggen-en-kademuren/","https://www.amsterdam.nl/projecten/kademuren"], 52.3731, 4.8925, cc="NL", status="in_progress", source_date="2026")
|
||||||
|
t(Y,"infrastructure","Renovate Rotterdam's 55 movable bridges and locks",
|
||||||
|
"Rotterdam's ten-year 'Open/Dicht' programme will tackle overdue maintenance on 55 movable bridges and locks, including the Erasmusbrug. The first six go to tender in 2026.",
|
||||||
|
"Rotterdam, NL", ["https://www.infrasite.nl/aanbesteden/2026/09/02/rotterdam-gaat-55-bruggen-en-sluizen-renoveren-aanbesteding-voor-o-a-erasmusbrug-start-dit-jaar/"], 51.9244, 4.4778, cc="NL", status="in_progress", source_date="2026-09-02")
|
||||||
|
t(Y,"infrastructure","Repair Leiden's 13 km of ageing quay walls",
|
||||||
|
"Leiden has started a 15-year programme for 13 km of quay walls at 114 locations, starting with the worst. The first big works (Oude Vest, Herengracht, Kalvermarkt) start in 2027.",
|
||||||
|
"Leiden, NL", ["https://gemeente.leiden.nl/projecten/verbeteren-kades/","https://www.infrasite.nl/waterbouw-deltas/2026/07/01/leiden-start-miljoenenoperatie-voor-verouderde-kademuren-in-historische-binnenstad/"], 52.1595, 4.4909, cc="NL", source_date="2026-07")
|
||||||
|
t(Y,"environment","Stop PFAS emissions from the Chemours plant in Dordrecht",
|
||||||
|
"The province of Zuid-Holland is appealing at the Raad van State for stricter PFAS limits on Chemours, with a ruling expected in autumn 2026, while DCMR monitors PFAS in air and rainfall around Dordrecht.",
|
||||||
|
"Dordrecht, NL", ["https://www.zuid-holland.nl/onderwerpen/milieu/chemours/","https://www.dcmr.nl/actueel/dossiers/dossier-chemours-delrin-dupont-en-dow"], 51.8105, 4.6458, cc="NL", source_date="2026")
|
||||||
|
t(Y,"housing","Get Utrecht's ~3,000 homeless people into housing",
|
||||||
|
"About 3,000 people in Utrecht are homeless or at risk of homelessness (AD). The city promised 100 'Wonen Eerst' homes for homeless young people in 2026, and the average wait for social housing is close to 12 years.",
|
||||||
|
"Utrecht, NL", ["https://www.ad.nl/utrecht/aantal-dak-en-thuislozen-in-utrecht-groeit-naar-3000-dit-schrijnende-verhaal-schuilt-achter-de-cijfers~aecf1218/","https://momusmedia.nl/updates/utrecht-belooft-100-woningen-aan-dakloze-jongeren-hoe-staat-het-ervoor/"], 52.0907, 5.1216, cc="NL", source_date="2026", confidence="medium", note="AD article date not confirmed; figures from search summary.")
|
||||||
|
t(Y,"infrastructure","Rebuild Dresden's collapsed Carolabrücke",
|
||||||
|
"The Carolabrücke collapsed in Sept 2024 and was fully demolished by Sept 2025. Planners were contracted in Sept 2026, with construction planned from May 2028 and completion in the early 2030s.",
|
||||||
|
"Dresden, DE", ["https://www.dresden.de/de/stadtraum/zentrale-projekte/carolabruecke.php","https://www.dresden.de/de/rathaus/aktuelles/pressemitteilungen/2026/09/pm_096.php"], 51.0568, 13.7470, cc="DE", status="in_progress", source_date="2026-09")
|
||||||
|
t(Y,"infrastructure","Reopen Hammersmith Bridge in London",
|
||||||
|
"Hammersmith Bridge has been closed to motor vehicles since 2019. Full restoration was costed at about £300m, plans to reopen it to cars have been shelved, and a cheaper phased repair is now being pursued.",
|
||||||
|
"London, UK", ["https://tfl.gov.uk/travel-information/improvements-and-projects/hammersmith-bridge","https://www.bbc.co.uk/news/articles/c5yzqv29d1eo"], 51.4884, -0.2302, cc="GB", source_date="2026-10")
|
||||||
|
t(Y,"climate","Stop Jakarta sinking: enforce the groundwater ban and pipe in water",
|
||||||
|
"North Jakarta sinks on average about 3.5 cm a year, up to 20 cm in places, and roughly 40% of the city is below sea level. Enforce the tightened groundwater-extraction rules and expand piped water.",
|
||||||
|
"Jakarta, ID", ["https://www.thejakartapost.com/indonesia/2026/02/09/jakarta-tightens-groundwater-rule-to-curb-subsidence","https://www.straitstimes.com/asia/se-asia/jakarta-sinks-deeper-as-subsidence-worsens-flood-risks"], -6.1754, 106.8272, cc="ID", source_date="2026-02")
|
||||||
|
t(Y,"health","Clean up Delhi's toxic winter air",
|
||||||
|
"Delhi's AQI passed 300 on 23 Oct 2026 even with GRAP Stage I in force, as crop-stubble fires in Punjab and Haryana resumed. Cut stubble burning, construction dust and vehicle emissions.",
|
||||||
|
"Delhi, IN", ["https://www.indiatoday.in/amp/science/story/delhi-aqi-today-pollution-control-measures-grap-ncr-smog-3023247-2026-10-23"], 28.6139, 77.2090, cc="IN", source_date="2026-10-23")
|
||||||
|
t(Y,"water","Fix Mexico City's leaking water network",
|
||||||
|
"Mexico City loses an estimated 40–50% of its drinking water to leaks in the public network and homes. Aquifer over-pumping makes parts of the city sink by up to about 24 cm a year.",
|
||||||
|
"Mexico City, MX", ["https://www.jornada.com.mx/2026/08/14/capital/027n1cap","https://politica.expansion.mx/mexico/2026/10/19/cutzamala-llenado-abasto-agua-valle-de-mexico"], 19.4326, -99.1332, cc="MX", source_date="2026-10")
|
||||||
|
t(Y,"climate","Unblock Lagos drainage channels to end flash flooding",
|
||||||
|
"Lagos State approved dredging of 28 drainage channels and has removed 1,544 of 2,218 identified structures blocking drainage. Refuse dumping and wetland encroachment still cause floods.",
|
||||||
|
"Lagos, NG", ["https://punchng.com/sanwo-olu-approves-dredging-of-28-drainage-channels-to-tackle-lagos-floods/","https://punchng.com/lasg-removes-over-1500-structures-to-tackle-flooding/"], 6.5244, 3.3792, cc="NG", status="in_progress", source_date="2026")
|
||||||
|
|
||||||
|
# ---------------- STREET / NEIGHBOURHOOD ----------------
|
||||||
|
S="street"
|
||||||
|
t(S,"infrastructure","Fix the sunken path at the new footbridge on Tesselschadestraat, Gouda",
|
||||||
|
"Paving at one end of the footbridge in Korte Akkeren, built in 2025, sank in July 2026 when sand washed out, leaving a hole. The council fenced off the crossing and said it would repair it as soon as possible.",
|
||||||
|
"Tesselschadestraat, Korte Akkeren, Gouda", ["https://www.goudsepost.nl/nieuws/nieuws/471080/gat-voor-brug-tesselschadestraat-na-verzakking-tegels","https://www.omroepwest.nl/verkeer/5130955/gat-ontstaan-pal-voor-bruggetje-oeververbinding-afgesloten"], 52.0014, 4.6979, cc="NL", source_date="2026-07-24", confidence="medium", note="Reported in July 2026; no repair confirmation found, but it may have been fixed since.")
|
||||||
|
t(S,"safety","Slow down fast bikes and scooters on the Aschpotpad dog-walking area, Gouda",
|
||||||
|
"The Aschpotpad in Achterwillens runs straight through an off-leash dog area. A dog was hit and killed by a cyclist on 23 July 2026, and Leefbaar Gouda asked the council for speed-reducing measures such as narrowings and posts.",
|
||||||
|
"Aschpotpad, Achterwillens, Gouda", ["https://www.goudsepost.nl/nieuws/nieuws/473975/leefbaar-gouda-stelt-vragen-over-veiligheid-aschpotpad-na-dood-hondje"], 52.0257, 4.7217, cc="NL", source_date="2026-08-19")
|
||||||
|
t(S,"mobility","Stop rat-run traffic on Onder de Boompjes, Gouda",
|
||||||
|
"With the Koningin Wilhelminaweg works, drivers ignore the closure signs and cut through narrow Onder de Boompjes. Police wrote 16 tickets in one hour on 22 Oct 2026 and will check more often. A physical measure would fix it for good.",
|
||||||
|
"Onder de Boompjes, Gouda", ["https://www.goudsepost.nl/nieuws/nieuws/482518/politie-bekeurt-16-automobilisten-in-een-uur-op-onder-de-boompjes","https://www.ad.nl/gouda/veel-boetes-voor-sluipverkeer-bij-onder-de-boompjes~adc6ac57/"], 52.0106, 4.6980, cc="NL", source_date="2026-10-22")
|
||||||
|
t(S,"infrastructure","Fix the dangerous sunken pavement on Schoonegge, Rotterdam-Zuidwijk",
|
||||||
|
"A resident broke her wrist after falling on sunken, uneven tiles. The council has had at least ten reports but postponed repair to 2027 (tree roots, no contractor). The Pendrecht-Zuidwijk wijkraad asks for a temporary safe path now.",
|
||||||
|
"Schoonegge, Zuidwijk, Rotterdam", ["https://dagblad010.nl/Rotterdam%20&%20Regio/wijkraad-eist-opheldering-over-gevaarlijk-trottoir-in-rotterdam-zuid-"], 51.8717, 4.4915, cc="NL", source_date="2026-10-08")
|
||||||
|
t(S,"water","Repair the underground water leak causing sinkholes on Crooswijksestraat, Rotterdam",
|
||||||
|
"An underground water leak washed away sand and opened holes in pavements and streets around Crooswijksestraat and Hendrikstraat, and flooded crawl spaces. Streets are fenced off and Evides is repairing the leak (25 Oct 2026).",
|
||||||
|
"Crooswijksestraat / Hendrikstraat, Crooswijk, Rotterdam", ["https://www.ad.nl/rotterdam/gaten-in-stoepen-en-straten-door-ondergrondse-lekkage-crooswijk~a2a95e15/"], 51.9329, 4.4912, cc="NL", status="in_progress", source_date="2026-10-25", confidence="medium", note="Repair was under way on 25 Oct; it will likely be done within days.")
|
||||||
|
t(S,"waste","Tackle dumped furniture, rats and overflowing containers in Oud-Mathenesse, Rotterdam",
|
||||||
|
"Residents of Oud-Mathenesse report furniture dumped on the street, rats ripping open bin bags and litter everywhere. The council says it is not a major problem, so residents are documenting it themselves (24 Oct 2026).",
|
||||||
|
"Oud-Mathenesse, Rotterdam", ["https://www.inderegiorotterdam.nl/nieuws/ratten-gedumpte-meubels-en-volle-containers-bewoners-willen-afvalprobleem-in-oud-mathenesse-zichtbaar-maken"], 51.9135, 4.4300, cc="NL", source_date="2026-10-24", confidence="medium", note="Coordinates are approximate for the neighbourhood.")
|
||||||
|
t(S,"safety","Get help and shelter for people sleeping under the Hofbogen, Agniesebuurt, Rotterdam",
|
||||||
|
"Open drug use and dealing, litter and vandalism are concentrated along the Hofbogen between former Hofplein station and Eudokiaplein. The council added BOAs, cleaning and outreach in Oct 2026, but evenings remain a problem and shelter capacity is short.",
|
||||||
|
"Hofbogen, Agniesebuurt, Rotterdam", ["https://www.ad.nl/rotterdam/bewoners-klagen-al-langer-over-drugs-alcohol-en-vernieling-rotterdam-komt-nu-met-maatregelen-voor-de-agniesebuurt~a09e0ac8/"], 51.9295, 4.4745, cc="NL", status="in_progress", source_date="2026-10-19", confidence="medium", note="Coordinates are approximate.")
|
||||||
|
t(S,"infrastructure","Stop the Schenkviaduct leaking rusty water onto cars on Weteringkade, Den Haag",
|
||||||
|
"Since at least 2008 the Schenkviaduct has dripped leak water with concrete rot and rust onto parked cars. The council admits it is not solved, and the viaduct's renovation sits in its Meerjarenprogramma Kunstwerken 2026–2029.",
|
||||||
|
"Schenkviaduct / Weteringkade, Den Haag", ["https://www.omroepwest.nl/verkeer/5163344/zorgen-om-viaduct-in-den-haag-straks-stort-het-in"], 52.0737, 4.3254, cc="NL", source_date="2026-10-24")
|
||||||
|
t(S,"mobility","Agree and build a safer layout for Hobbemaplein by the Haagse Markt, Den Haag",
|
||||||
|
"The previous redesign lacked support. Den Haag restarted the plan in 2026 with about €8.5M to make the square safer for pedestrians, cyclists and public transport while keeping the market reachable.",
|
||||||
|
"Hobbemaplein, Transvaalkwartier, Den Haag", ["https://www.denhaag.nl/nl/denk-mee/herinrichting-hobbemaplein/"], 52.0668, 4.2969, cc="NL", status="in_progress", source_date="2026")
|
||||||
|
t(S,"infrastructure","Re-lay the broken paving along the tram tracks on Reguliersbreestraat, Amsterdam",
|
||||||
|
"Shopkeepers say people regularly fall on the crooked cobbles around the tram rails. GVB only patches it, and a structural fix is not planned for 2026 (Parool, 4 Oct 2026).",
|
||||||
|
"Reguliersbreestraat, Amsterdam", ["https://www.parool.nl/amsterdam/ondernemers-balen-van-scheve-straatstenen-in-reguliersbreestraat-mensen-vallen-met-regelmaat~b180b619/"], 52.3666, 4.8947, cc="NL", source_date="2026-10-04")
|
||||||
|
t(S,"infrastructure","Replace the moving quay wall at Oudeschans 1A–39, Amsterdam",
|
||||||
|
"The quay wall between Oosterse Kade and Keizersbrug was moving toward the water. A temporary sheet-pile safety structure went in during spring 2026, and full replacement is planned to start in 2028.",
|
||||||
|
"Oudeschans 1A–39, Amsterdam", ["https://www.amsterdam.nl/projecten/kademuren/maatregelen/oudeschans1a-39/"], 52.3713, 4.9052, cc="NL", status="in_progress", source_date="2026")
|
||||||
|
t(S,"mobility","Make the Meernbrug crossing in De Meern safe",
|
||||||
|
"Several traffic flows cross at the Meernbrug, and residents have been worried for years. Utrecht favours one-way traffic on the bridge, and a costed plan is due in Q1 2027 (commissie Leefomgeving, 8 Oct 2026).",
|
||||||
|
"Meernbrug, De Meern, Utrecht", ["https://openbestuur.nl/gemeente/utrecht/vergadering/2026-10-08-raad-zoekt-besteding-voor-half-miljoen-aan-verkeersveiligheid"], 52.0829, 5.0369, cc="NL", source_date="2026-10-08")
|
||||||
|
t(S,"safety","End the nightly fireworks and arson nuisance in Ondiep, Utrecht",
|
||||||
|
"Since September 2026, residents of Ondiep have faced heavy fireworks almost every night, burning bins and a vandalised parking meter. The council announced extra police, enforcement and changes to the public space.",
|
||||||
|
"Ondiep, Utrecht", ["https://www.rtvutrecht.nl/nieuws/4106957/bewoners-utrechtse-wijk-al-weken-geteisterd-door-overlast-lijkt-wel-oorlogsgebied","https://nos.nl/regio/utrecht/artikel/812003-utrecht-neemt-maatregelen-tegen-vuurwerkterreur-ondiep"], 52.1066, 5.1014, cc="NL", status="in_progress", source_date="2026-10-22")
|
||||||
|
t(S,"water","Clear the blocked drain flooding Raeburn Place / Allan Street, Edinburgh",
|
||||||
|
"A FixMyStreet report (24 Oct 2026) says a blocked drain at the junction of Raeburn Place and Allan Street floods the junction and 'has been an issue for some time'.",
|
||||||
|
"Raeburn Place, Stockbridge, Edinburgh", ["https://www.fixmystreet.com/report/10279290"], 55.959076, -3.213709, cc="GB", source_date="2026-10-24")
|
||||||
|
t(S,"waste","Remove the horsebox abandoned for years on St Clair Place, Leith",
|
||||||
|
"A FixMyStreet report (24 Oct 2026) says a horsebox has stood unmoved on St Clair Place for several years, with its roof window open.",
|
||||||
|
"St Clair Place, Leith, Edinburgh", ["https://www.fixmystreet.com/report/10278913"], 55.964045, -3.169505, cc="GB", source_date="2026-10-24")
|
||||||
|
t(S,"infrastructure","Make safe the broken manhole cover on Old Dalkeith Road, Edinburgh",
|
||||||
|
"Part of a manhole cover in the middle of the road between the Royal Infirmary and Ferniehill Road has fallen into the manhole, leaving it exposed (FixMyStreet, 25 Oct 2026).",
|
||||||
|
"Old Dalkeith Road, Edinburgh", ["https://www.fixmystreet.com/report/10283120"], 55.917126, -3.132213, cc="GB", source_date="2026-10-25")
|
||||||
|
t(S,"infrastructure","Fill the deep pothole with exposed jagged metal on Passage Road, Bristol",
|
||||||
|
"A deep pothole with exposed jagged metal has damaged a car. Bristol City Council raised an enquiry on 25 Oct 2026 (FixMyStreet).",
|
||||||
|
"Passage Road, Westbury on Trym, Bristol", ["https://www.fixmystreet.com/report/10283769"], 51.505769, -2.615863, cc="GB", source_date="2026-10-25")
|
||||||
|
t(S,"safety","Replace the broken glass pavement panel on Aldwych by Bush House, London",
|
||||||
|
"A broken glass panel set into the pavement at the Aldwych side entrance to Bush House Central Block leaves a hole that people can trip in or drop things into (FixMyStreet, 20 Oct 2026).",
|
||||||
|
"Aldwych, Westminster, London", ["https://www.fixmystreet.com/report/10261310"], 51.513011, -0.117094, cc="GB", source_date="2026-10-20")
|
||||||
|
t(S,"waste","Clear the fly-tipped sofa, doors and carpets beside 6A Turner Street, Gorton, Manchester",
|
||||||
|
"A three-seater sofa, an armchair, four wooden doors and four large carpets were dumped in the car park next to 6A Turner Street (FixMyStreet, 23 Oct 2026).",
|
||||||
|
"Turner Street, Gorton, Manchester", ["https://www.fixmystreet.com/report/10274767"], 53.463819, -2.171036, cc="GB", source_date="2026-10-23")
|
||||||
|
|
||||||
|
out = {"as_of": AS_OF, "schema_version": 1,
|
||||||
|
"scales": ["planet","region","country","city","street"],
|
||||||
|
"categories": sorted({x["category"] for x in T}),
|
||||||
|
"tasks": T}
|
||||||
|
json.dump(out, open("/workspace/worldwide-todo/seed.json","w"), ensure_ascii=False, indent=2)
|
||||||
|
from collections import Counter
|
||||||
|
print(len(T), Counter(x["scale"] for x in T), Counter(x["status"] for x in T))
|
||||||
@@ -235,6 +235,33 @@ try {
|
|||||||
movies.public_usage
|
movies.public_usage
|
||||||
)
|
)
|
||||||
throw new Error(`movies schema tables/grants are wrong: ${JSON.stringify(movies)}`);
|
throw new Error(`movies schema tables/grants are wrong: ${JSON.stringify(movies)}`);
|
||||||
|
// Worldwide todo list (public pages, requests and owner approvals go through the app): app
|
||||||
|
// role read/write, personal_ro none. 3 = categories, tasks, change_requests.
|
||||||
|
const WORLDTODO_TABLES = 3;
|
||||||
|
const [worldtodo] = await client`
|
||||||
|
select
|
||||||
|
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||||
|
count(*) filter (
|
||||||
|
where exists (
|
||||||
|
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
||||||
|
where r.rolname = 'personal_ro'
|
||||||
|
)
|
||||||
|
) as ro_read,
|
||||||
|
count(*) as tables,
|
||||||
|
has_schema_privilege('public', 'worldtodo', 'usage') as public_usage
|
||||||
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'worldtodo' and c.relkind = 'r'
|
||||||
|
`;
|
||||||
|
if (
|
||||||
|
Number(worldtodo.tables) !== WORLDTODO_TABLES ||
|
||||||
|
Number(worldtodo.app_rw) !== WORLDTODO_TABLES ||
|
||||||
|
Number(worldtodo.ro_read) !== 0 ||
|
||||||
|
worldtodo.public_usage
|
||||||
|
)
|
||||||
|
throw new Error(`worldtodo schema tables/grants are wrong: ${JSON.stringify(worldtodo)}`);
|
||||||
|
const [{ n: worldtodoTasks }] = await client`select count(*)::int as n from worldtodo.tasks`;
|
||||||
|
if (worldtodoTasks < 68)
|
||||||
|
throw new Error(`worldtodo.tasks has ${worldtodoTasks} rows, expected the 68 seed tasks`);
|
||||||
// Energy tracker reads the ned-collector tables (SELECT only).
|
// Energy tracker reads the ned-collector tables (SELECT only).
|
||||||
const [energy] = await client`
|
const [energy] = await client`
|
||||||
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
||||||
@@ -244,7 +271,7 @@ try {
|
|||||||
if (!Number(energy.hours))
|
if (!Number(energy.hours))
|
||||||
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
||||||
console.log(
|
console.log(
|
||||||
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||||
);
|
);
|
||||||
const [catalog] = await client`
|
const [catalog] = await client`
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ if [ "$mode" = deploy ]; then
|
|||||||
run_database_script scripts/seed-pokemon-research.ts
|
run_database_script scripts/seed-pokemon-research.ts
|
||||||
run_database_script scripts/seed-cod-challenges.ts
|
run_database_script scripts/seed-cod-challenges.ts
|
||||||
run_database_script scripts/seed-cod-art.ts
|
run_database_script scripts/seed-cod-art.ts
|
||||||
|
# Worldwide todo list: categories + 68 sourced tasks, insert-only (never overwrites edits).
|
||||||
|
run_database_script scripts/seed-worldtodo.ts
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
||||||
|
|||||||
87
scripts/seed-worldtodo.ts
Normal file
87
scripts/seed-worldtodo.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
// Seeds the Worldwide Todo List (schema worldtodo) from data/worldtodo-seed.json, built by
|
||||||
|
// scripts/build-worldtodo-seed.py (68 sourced tasks as of 2026-10-26). Idempotent: categories
|
||||||
|
// are upserted, tasks are inserted with ON CONFLICT (slug) DO NOTHING, so owner edits and
|
||||||
|
// approved deletes (soft, the row stays) are never overwritten. Runs on every deploy
|
||||||
|
// (scripts/deploy-remote.sh) as the schema owner; self-contained so it works in the app image.
|
||||||
|
// bun scripts/seed-worldtodo.ts
|
||||||
|
|
||||||
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||||
|
|
||||||
|
type SeedTask = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
scale: string;
|
||||||
|
category: string;
|
||||||
|
location_name: string;
|
||||||
|
country_code: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
status: string;
|
||||||
|
source_urls: string[];
|
||||||
|
source_date: string | null;
|
||||||
|
as_of: string;
|
||||||
|
confidence: string;
|
||||||
|
verification_note?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lucide icon key (#lib/worldtodo/icons) and an accent colour for the icon tile.
|
||||||
|
const CATEGORIES: Record<string, { label: string; icon: string; colour: string }> = {
|
||||||
|
climate: { label: 'Climate', icon: 'thermometer', colour: '#ef4444' },
|
||||||
|
environment: { label: 'Environment', icon: 'leaf', colour: '#22c55e' },
|
||||||
|
energy: { label: 'Energy', icon: 'zap', colour: '#eab308' },
|
||||||
|
food: { label: 'Food', icon: 'wheat', colour: '#d97706' },
|
||||||
|
governance: { label: 'Governance', icon: 'landmark', colour: '#6366f1' },
|
||||||
|
health: { label: 'Health', icon: 'heart-pulse', colour: '#ec4899' },
|
||||||
|
housing: { label: 'Housing', icon: 'house', colour: '#f97316' },
|
||||||
|
humanitarian: { label: 'Humanitarian', icon: 'hand-heart', colour: '#e11d48' },
|
||||||
|
infrastructure: { label: 'Infrastructure', icon: 'construction', colour: '#0ea5e9' },
|
||||||
|
mobility: { label: 'Mobility', icon: 'bike', colour: '#14b8a6' },
|
||||||
|
safety: { label: 'Safety', icon: 'shield-alert', colour: '#f59e0b' },
|
||||||
|
waste: { label: 'Waste', icon: 'recycle', colour: '#84cc16' },
|
||||||
|
water: { label: 'Water', icon: 'droplet', colour: '#3b82f6' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const seed = JSON.parse(
|
||||||
|
await readFile(new URL('../data/worldtodo-seed.json', import.meta.url), 'utf8')
|
||||||
|
) as { categories: string[]; tasks: SeedTask[] };
|
||||||
|
|
||||||
|
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
await client.begin(async (tx) => {
|
||||||
|
for (const slug of seed.categories) {
|
||||||
|
const c = CATEGORIES[slug] ?? { label: slug, icon: 'list-todo', colour: '#a3a3a3' };
|
||||||
|
await tx`insert into worldtodo.categories (slug, label, icon, colour)
|
||||||
|
values (${slug}, ${c.label}, ${c.icon}, ${c.colour})
|
||||||
|
on conflict (slug) do update set label = excluded.label, icon = excluded.icon,
|
||||||
|
colour = excluded.colour`;
|
||||||
|
}
|
||||||
|
let inserted = 0;
|
||||||
|
for (const t of seed.tasks) {
|
||||||
|
const rows = await tx`
|
||||||
|
insert into worldtodo.tasks (slug, title, description, scale, category, location_name,
|
||||||
|
country_code, lat, lon, status, source_urls, source_date, as_of, confidence,
|
||||||
|
verification_note)
|
||||||
|
values (${t.slug}, ${t.title}, ${t.description}, ${t.scale}::worldtodo.scale,
|
||||||
|
${t.category}, ${t.location_name}, ${t.country_code ?? null}, ${t.lat ?? null},
|
||||||
|
${t.lon ?? null}, ${t.status}::worldtodo.status, ${t.source_urls},
|
||||||
|
${t.source_date ?? null}, ${t.as_of}, ${t.confidence ?? 'high'},
|
||||||
|
${t.verification_note ?? null})
|
||||||
|
on conflict (slug) do nothing
|
||||||
|
returning id`;
|
||||||
|
inserted += rows.length;
|
||||||
|
}
|
||||||
|
const [{ n }] = await tx`select count(*)::int as n from worldtodo.tasks
|
||||||
|
where slug = any(${seed.tasks.map((t) => t.slug)})`;
|
||||||
|
if (n !== seed.tasks.length)
|
||||||
|
throw new Error(`Expected ${seed.tasks.length} seed tasks, found ${n}`);
|
||||||
|
console.log(
|
||||||
|
`Worldwide todo: ${seed.categories.length} categories, ${inserted} new of ${seed.tasks.length} seed tasks (all present)`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
@@ -30,5 +30,8 @@ export const variables = defineEnvVars({
|
|||||||
UBI_EMAIL: { schema: (input) => input ?? '' },
|
UBI_EMAIL: { schema: (input) => input ?? '' },
|
||||||
UBI_PASSWORD: { schema: (input) => input ?? '' },
|
UBI_PASSWORD: { schema: (input) => input ?? '' },
|
||||||
GAMES_POLLER: { 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 { startGamesPoller } from '#lib/server/games/poller.js';
|
||||||
import { startOsuGoalsPoller } from '#lib/server/osu-goals/poller.js';
|
import { startOsuGoalsPoller } from '#lib/server/osu-goals/poller.js';
|
||||||
import { registerPubgMapChannel } from '#lib/server/pubg-map/live.js';
|
import { registerPubgMapChannel } from '#lib/server/pubg-map/live.js';
|
||||||
|
import { registerWorldtodoChannels } from '#lib/server/worldtodo/live.js';
|
||||||
import { building } from '$app/env';
|
import { building } from '$app/env';
|
||||||
import { auth } from '#lib/server/auth.js';
|
import { auth } from '#lib/server/auth.js';
|
||||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.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)
|
// 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
|
// 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 = () => {
|
export const init: ServerInit = () => {
|
||||||
if (!building) {
|
if (!building) {
|
||||||
startMusicPoller();
|
startMusicPoller();
|
||||||
startGamesPoller();
|
startGamesPoller();
|
||||||
startOsuGoalsPoller();
|
startOsuGoalsPoller();
|
||||||
registerPubgMapChannel();
|
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 Globe2 from 'lucide-svelte/icons/globe-2';
|
||||||
import Languages from 'lucide-svelte/icons/languages';
|
import Languages from 'lucide-svelte/icons/languages';
|
||||||
import ListChecks from 'lucide-svelte/icons/list-checks';
|
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 Music2 from 'lucide-svelte/icons/music-2';
|
||||||
import PawPrint from 'lucide-svelte/icons/paw-print';
|
import PawPrint from 'lucide-svelte/icons/paw-print';
|
||||||
import Trophy from 'lucide-svelte/icons/trophy';
|
import Trophy from 'lucide-svelte/icons/trophy';
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
'cs prices': CandlestickChart,
|
'cs prices': CandlestickChart,
|
||||||
'house tracker': House,
|
'house tracker': House,
|
||||||
lists: ListChecks,
|
lists: ListChecks,
|
||||||
|
'worldwide todo list': ListTodo,
|
||||||
health: HeartPulse,
|
health: HeartPulse,
|
||||||
books: BookOpen,
|
books: BookOpen,
|
||||||
stats: ChartLine,
|
stats: ChartLine,
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export const projects = [
|
|||||||
href: 'https://esports.vash.software',
|
href: 'https://esports.vash.software',
|
||||||
external: true
|
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',
|
name: 'osu! map finder',
|
||||||
subtitle: 'find osu! maps for Spotify songs/albums/playlists',
|
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',
|
title: 'Pokémon locations',
|
||||||
description: 'Every Pokémon location by region, with the Pokémon you can find there.'
|
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': {
|
'/projects/animaldex': {
|
||||||
title: 'Animaldex',
|
title: 'Animaldex',
|
||||||
description: 'Find and add neighborhood animals on a map.'
|
description: 'Find and add neighborhood animals on a map.'
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ export * from './stats';
|
|||||||
export * from './pubg-map';
|
export * from './pubg-map';
|
||||||
export * from './ai';
|
export * from './ai';
|
||||||
export * from './movies';
|
export * from './movies';
|
||||||
|
export * from './worldtodo';
|
||||||
export * from './osu-goals';
|
export * from './osu-goals';
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ export const dataholdersSchema = pgSchema('dataholders');
|
|||||||
export const statsSchema = pgSchema('stats');
|
export const statsSchema = pgSchema('stats');
|
||||||
export const aiSchema = pgSchema('ai');
|
export const aiSchema = pgSchema('ai');
|
||||||
export const moviesSchema = pgSchema('movies');
|
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',
|
'/trackers/ai/usage',
|
||||||
'/lists/db',
|
'/lists/db',
|
||||||
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
// 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;
|
] 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 { gameTrackers } from '#lib/projects.js';
|
||||||
import { and, eq, isNull, ne } from 'drizzle-orm';
|
import { and, eq, isNull, ne } from 'drizzle-orm';
|
||||||
import { getVisibility } from '#lib/server/movies/site.js';
|
import { getVisibility } from '#lib/server/movies/site.js';
|
||||||
|
import { sitemapSlugs } from '#lib/server/worldtodo/store.js';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
const staticPaths = [
|
const staticPaths = [
|
||||||
@@ -49,6 +50,7 @@ const staticPaths = [
|
|||||||
'/projects/game-tracker/pokemon',
|
'/projects/game-tracker/pokemon',
|
||||||
'/projects/game-tracker/osu',
|
'/projects/game-tracker/osu',
|
||||||
'/projects/game-tracker/pokemon/locations',
|
'/projects/game-tracker/pokemon/locations',
|
||||||
|
'/projects/worldwide-todo',
|
||||||
'/projects/animaldex',
|
'/projects/animaldex',
|
||||||
'/projects/derby',
|
'/projects/derby',
|
||||||
'/projects/spotify-beatmap-finder',
|
'/projects/spotify-beatmap-finder',
|
||||||
@@ -70,7 +72,7 @@ function escapeXml(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
const [articles, lists, books, moviesVisibility] = await Promise.all([
|
const [articles, lists, books, moviesVisibility, worldtodo] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
|
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
|
||||||
.from(blogArticles)
|
.from(blogArticles)
|
||||||
@@ -81,7 +83,8 @@ export const GET: RequestHandler = async () => {
|
|||||||
.from(bookWorks)
|
.from(bookWorks)
|
||||||
// Articles are links to other sites; their pages here are thin.
|
// Articles are links to other sites; their pages here are thin.
|
||||||
.where(and(ne(bookWorks.kind, 'article'), isNull(bookWorks.archivedAt))),
|
.where(and(ne(bookWorks.kind, 'article'), isNull(bookWorks.archivedAt))),
|
||||||
getVisibility()
|
getVisibility(),
|
||||||
|
sitemapSlugs().catch(() => [])
|
||||||
]);
|
]);
|
||||||
const entries: { path: string; lastmod?: string }[] = [
|
const entries: { path: string; lastmod?: string }[] = [
|
||||||
...staticPaths
|
...staticPaths
|
||||||
@@ -96,6 +99,10 @@ export const GET: RequestHandler = async () => {
|
|||||||
...books.map((book) => ({
|
...books.map((book) => ({
|
||||||
path: `/trackers/books/${book.id}`,
|
path: `/trackers/books/${book.id}`,
|
||||||
lastmod: book.updatedAt.toISOString()
|
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
|
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 TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||||
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
||||||
import Sparkline from '#lib/components/Sparkline.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();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -337,7 +337,9 @@
|
|||||||
.flatMap((t) => [t, ...(t.children ?? [])])
|
.flatMap((t) => [t, ...(t.children ?? [])])
|
||||||
.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })),
|
.map((t) => ({ name: t.name, href: t.href, kind: 'tracker' })),
|
||||||
...extraHits.map((t) => ({ ...t, 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 hits = $derived.by(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -569,6 +571,9 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<a class="chip" href="/stats" title="Economy, energy and Gouda statistics">stats</a>
|
<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}
|
{#if data.weight}<a class="chip" href="/trackers/health#body" title={data.weight.date}
|
||||||
>{data.weight.kg} kg</a
|
>{data.weight.kg} kg</a
|
||||||
>{/if}
|
>{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user