worldwide todo list: global map (List/Map toggle, clustered category pins, Global strip, pick on map, location_precision, tasks.geojson)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
This commit is contained in:
@@ -18,18 +18,20 @@ export const load: PageServerLoad = async ({ url, parent }) => {
|
||||
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 view = first('view') === 'map' ? 'map' : 'list';
|
||||
const filter = { scale, category, status, q };
|
||||
const [list, totals] = await Promise.all([listTasks(filter, sort, 500), counts(filter)]);
|
||||
return {
|
||||
filter,
|
||||
sort,
|
||||
view,
|
||||
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'
|
||||
index: !scale && !category && status === 'todo' && !q && sort === 'updated' && view === 'list'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TaskCard from '#lib/worldtodo/TaskCard.svelte';
|
||||
import WorldMap from '#lib/worldtodo/WorldMap.svelte';
|
||||
import StatusPill from '#lib/worldtodo/StatusPill.svelte';
|
||||
import IconTile from '#lib/worldtodo/IconTile.svelte';
|
||||
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
|
||||
import { subscribe } from '#lib/worldtodo/live';
|
||||
@@ -12,6 +14,9 @@
|
||||
type PublicTask
|
||||
} from '#lib/worldtodo/shared';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import List from 'lucide-svelte/icons/list';
|
||||
import MapIcon from 'lucide-svelte/icons/map';
|
||||
import Earth from 'lucide-svelte/icons/earth';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -90,6 +95,31 @@
|
||||
};
|
||||
|
||||
const f = $derived(data.filter);
|
||||
const isMap = $derived(data.view === 'map');
|
||||
// List / Map toggle keeps every filter in the URL (?view=map is shareable).
|
||||
const viewHref = (view: 'list' | 'map') => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
|
||||
const p = new URLSearchParams();
|
||||
if (f.scale) p.set('scale', f.scale);
|
||||
if (f.category) p.set('category', f.category);
|
||||
if (f.status !== 'todo') p.set('status', f.status);
|
||||
if (f.q) p.set('q', f.q);
|
||||
if (data.sort !== 'updated') p.set('sort', data.sort);
|
||||
if (view === 'map') p.set('view', 'map');
|
||||
const s = p.toString();
|
||||
return `/projects/worldwide-todo${s ? `?${s}` : ''}`;
|
||||
};
|
||||
const pinnable = (t: PublicTask) => t.locationPrecision !== 'none';
|
||||
const globalTasks = $derived(tasks.filter((t) => !pinnable(t)));
|
||||
const mapListed = $derived(tasks.filter(pinnable));
|
||||
const fitKey = $derived(JSON.stringify(data.filter));
|
||||
let mapRef = $state<ReturnType<typeof WorldMap>>();
|
||||
let hovered = $state<string | null>(null);
|
||||
function cardClick(e: MouseEvent, task: PublicTask) {
|
||||
if (!isMap || !mapRef || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return;
|
||||
e.preventDefault();
|
||||
mapRef.focusTask(task.slug);
|
||||
}
|
||||
const total = $derived(data.counts.status.open + data.counts.status.in_progress);
|
||||
</script>
|
||||
|
||||
@@ -185,16 +215,75 @@
|
||||
{#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}
|
||||
{#if isMap}<input type="hidden" name="view" value="map" />{/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>
|
||||
<div class="results-bar">
|
||||
<p class="results" aria-live="polite">
|
||||
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q} matching “{f.q}”{/if}
|
||||
{#if isMap}· {mapListed.length} on the map{/if}
|
||||
{#if f.scale || f.category || f.q || f.status !== 'todo'}
|
||||
· <a
|
||||
href={isMap ? '/projects/worldwide-todo?view=map' : '/projects/worldwide-todo'}
|
||||
data-sveltekit-noscroll>clear filters</a
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
<nav class="views" aria-label="View">
|
||||
<a
|
||||
href={viewHref('list')}
|
||||
class={{ on: !isMap }}
|
||||
aria-current={!isMap ? 'page' : undefined}
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-keepfocus><List size={15} aria-hidden="true" />List</a
|
||||
>
|
||||
<a
|
||||
href={viewHref('map')}
|
||||
class={{ on: isMap }}
|
||||
aria-current={isMap ? 'page' : undefined}
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-keepfocus><MapIcon size={15} aria-hidden="true" />Map</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<p class="sr-only" aria-live="polite">{announce}</p>
|
||||
|
||||
{#if isMap}
|
||||
{#if globalTasks.length}
|
||||
<section class="global" aria-label="Global tasks (no single place on the map)">
|
||||
<h2><Earth size={15} aria-hidden="true" />Global <span>{globalTasks.length}</span></h2>
|
||||
<ul>
|
||||
{#each globalTasks as task (task.slug)}
|
||||
{@const cat = catBySlug.get(task.category)}
|
||||
<li>
|
||||
<a
|
||||
href="/projects/worldwide-todo/{task.slug}"
|
||||
class={{ flash: flashing === task.slug }}
|
||||
>
|
||||
<span class="g-top">
|
||||
<span class="badge">{task.scale === 'planet' ? 'Global' : 'No pin'}</span>
|
||||
<span class="g-cat"
|
||||
><i style="background:{cat?.colour ?? '#737373'}"></i>{cat?.label ??
|
||||
task.category}</span
|
||||
>
|
||||
<StatusPill status={task.status} />
|
||||
</span>
|
||||
<span class="g-title">{task.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
<WorldMap
|
||||
bind:this={mapRef}
|
||||
tasks={mapListed}
|
||||
categories={data.categories}
|
||||
highlight={hovered}
|
||||
{fitKey}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ul class="grid">
|
||||
<li>
|
||||
<a class="suggest" href="/projects/worldwide-todo/request?kind=add">
|
||||
@@ -203,8 +292,14 @@
|
||||
<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>
|
||||
{#each isMap ? mapListed : tasks as task (task.slug)}
|
||||
<li
|
||||
onmouseenter={() => (hovered = task.slug)}
|
||||
onmouseleave={() => (hovered = hovered === task.slug ? null : hovered)}
|
||||
onfocusin={() => (hovered = task.slug)}
|
||||
onfocusout={() => (hovered = hovered === task.slug ? null : hovered)}
|
||||
onclickcapture={(e) => cardClick(e, task)}
|
||||
>
|
||||
<TaskCard
|
||||
{task}
|
||||
category={catBySlug.get(task.category)}
|
||||
@@ -379,6 +474,133 @@
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.results-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.views {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.views a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 550;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
color 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
.views a:hover,
|
||||
.views a:focus-visible {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.views a.on {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
box-shadow: inset 0 0 0 1px var(--site-text);
|
||||
}
|
||||
.global h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.global h2 :global(svg) {
|
||||
color: #38bdf8;
|
||||
}
|
||||
.global h2 span {
|
||||
color: var(--site-muted);
|
||||
font-weight: 450;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.global ul {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(15rem, 18rem);
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.35rem;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.global li {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.global a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
height: 100%;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
background-color 0.6s;
|
||||
}
|
||||
.global a:hover,
|
||||
.global a:focus-visible {
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.global a.flash {
|
||||
background: color-mix(in srgb, var(--site-surface) 80%, #facc15);
|
||||
}
|
||||
.g-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.badge {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #38bdf8, #16a34a);
|
||||
}
|
||||
.g-cat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.g-cat i {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.g-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 550;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.results a {
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { listTasks } from '#lib/server/worldtodo/store.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// GET /projects/worldwide-todo/api/tasks.geojson?scale=&category=&status=&q=
|
||||
// Every task with a map position (precision exact or approximate) as a GeoJSON
|
||||
// FeatureCollection, same filters as /api/tasks. Planet tasks and tasks without coordinates
|
||||
// are left out (precision 'none').
|
||||
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 category = /^[a-z][a-z0-9-]{1,30}$/.test(p('category')) ? p('category') : '';
|
||||
const { tasks } = await listTasks(
|
||||
{ scale, category, status, q: p('q').slice(0, 100) },
|
||||
'updated',
|
||||
1000
|
||||
);
|
||||
const features = tasks
|
||||
.filter((t) => t.locationPrecision !== 'none' && t.lat !== null && t.lon !== null)
|
||||
.map((t) => ({
|
||||
type: 'Feature' as const,
|
||||
id: t.id,
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lon!, t.lat!] },
|
||||
properties: {
|
||||
slug: t.slug,
|
||||
title: t.title,
|
||||
scale: t.scale,
|
||||
category: t.category,
|
||||
status: t.status,
|
||||
location_name: t.locationName,
|
||||
location_precision: t.locationPrecision,
|
||||
url: `${url.origin}/projects/worldwide-todo/${t.slug}`
|
||||
}
|
||||
}));
|
||||
return new Response(JSON.stringify({ type: 'FeatureCollection', features }), {
|
||||
headers: {
|
||||
'content-type': 'application/geo+json; charset=utf-8',
|
||||
'cache-control': 'public, max-age=30'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -60,6 +60,16 @@
|
||||
new: formatValue(k, change[k])
|
||||
}));
|
||||
}
|
||||
/** Coordinates the request proposes (picked on the map or typed), to check before approving. */
|
||||
function proposedPosition(item: Item) {
|
||||
const p = item.payload as Record<string, unknown>;
|
||||
if (item.kind === 'delete' || !('lat' in p || 'lon' in p)) return null;
|
||||
const lat = Number(p.lat ?? item.task?.lat);
|
||||
const lon = Number(p.lon ?? item.task?.lon);
|
||||
return p.lat !== null && p.lon !== null && Number.isFinite(lat) && Number.isFinite(lon)
|
||||
? { lat, lon }
|
||||
: null;
|
||||
}
|
||||
function tweakValues(item: Item) {
|
||||
const base = item.task ? fieldsOf(item.task) : {};
|
||||
return { ...base, ...item.payload } as Record<string, unknown>;
|
||||
@@ -194,6 +204,15 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{@const pos = proposedPosition(item)}
|
||||
{#if pos}
|
||||
<a
|
||||
class="osm"
|
||||
href="https://www.openstreetmap.org/?mlat={pos.lat}&mlon={pos.lon}#map=15/{pos.lat}/{pos.lon}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Check the proposed map position on OpenStreetMap ↗</a
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<dl class="meta">
|
||||
@@ -573,4 +592,10 @@
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.osm {
|
||||
display: inline-block;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--site-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user