Segments phase 2: /segments/ browse page, /segments/new/ creation flow, activity detail shortcut
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { formatDistance, formatDate, sportIcon } from '../lib/format';
|
||||
|
||||
export let base: string = '/';
|
||||
|
||||
interface Segment {
|
||||
id: string;
|
||||
name: string;
|
||||
sport: string | null;
|
||||
distance_m: number;
|
||||
polyline: [number, number][];
|
||||
bbox: [number, number, number, number];
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let mapEl: HTMLDivElement;
|
||||
let map: any;
|
||||
let segments: Segment[] = [];
|
||||
let loading = false;
|
||||
let selectedId: string | null = null;
|
||||
let fetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron';
|
||||
const SEG_COLOR = '#f59e0b';
|
||||
const SEG_SEL = '#3b82f6';
|
||||
|
||||
$: selectedSeg = segments.find(s => s.id === selectedId) ?? null;
|
||||
|
||||
onMount(() => {
|
||||
map = new maplibregl.Map({
|
||||
container: mapEl,
|
||||
style: TILE_STYLE,
|
||||
center: [12, 45],
|
||||
zoom: 8,
|
||||
attributionControl: false,
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('segments', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
});
|
||||
|
||||
// Wider invisible hit area for easier clicking
|
||||
map.addLayer({ id: 'seg-hit', type: 'line', source: 'segments',
|
||||
paint: { 'line-color': 'transparent', 'line-width': 14 } });
|
||||
|
||||
map.addLayer({ id: 'seg-line', type: 'line', source: 'segments',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: {
|
||||
'line-color': ['case', ['==', ['get', 'id'], selectedId ?? ''], SEG_SEL, SEG_COLOR],
|
||||
'line-width': ['case', ['==', ['get', 'id'], selectedId ?? ''], 5, 3],
|
||||
},
|
||||
});
|
||||
|
||||
map.on('click', 'seg-hit', (e: any) => {
|
||||
const id = e.features?.[0]?.properties?.id;
|
||||
if (id) selectedId = selectedId === id ? null : id;
|
||||
});
|
||||
map.on('mouseenter', 'seg-hit', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', 'seg-hit', () => { map.getCanvas().style.cursor = ''; });
|
||||
|
||||
fetchSegments();
|
||||
});
|
||||
|
||||
map.on('moveend', () => {
|
||||
if (fetchTimer) clearTimeout(fetchTimer);
|
||||
fetchTimer = setTimeout(fetchSegments, 300);
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (fetchTimer) clearTimeout(fetchTimer);
|
||||
map?.remove();
|
||||
});
|
||||
|
||||
async function fetchSegments() {
|
||||
if (!map) return;
|
||||
const b = map.getBounds();
|
||||
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].map(v => v.toFixed(5)).join(',');
|
||||
loading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/segments?bbox=${bbox}`, { credentials: 'include' });
|
||||
if (!r.ok) return;
|
||||
segments = await r.json();
|
||||
updateMapSource();
|
||||
} catch { /* ignore */ }
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function updateMapSource() {
|
||||
if (!map?.getSource('segments')) return;
|
||||
map.getSource('segments').setData({
|
||||
type: 'FeatureCollection',
|
||||
features: segments.map(s => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: s.polyline.map(([lat, lon]) => [lon, lat]),
|
||||
},
|
||||
properties: { id: s.id, name: s.name },
|
||||
})),
|
||||
});
|
||||
// Repaint selection colour
|
||||
if (map.getLayer('seg-line')) {
|
||||
map.setPaintProperty('seg-line', 'line-color',
|
||||
['case', ['==', ['get', 'id'], selectedId ?? ''], SEG_SEL, SEG_COLOR]);
|
||||
map.setPaintProperty('seg-line', 'line-width',
|
||||
['case', ['==', ['get', 'id'], selectedId ?? ''], 5, 3]);
|
||||
}
|
||||
}
|
||||
|
||||
$: if (map?.getLayer('seg-line') && selectedId !== undefined) {
|
||||
map.setPaintProperty('seg-line', 'line-color',
|
||||
['case', ['==', ['get', 'id'], selectedId ?? ''], SEG_SEL, SEG_COLOR]);
|
||||
map.setPaintProperty('seg-line', 'line-width',
|
||||
['case', ['==', ['get', 'id'], selectedId ?? ''], 5, 3]);
|
||||
}
|
||||
|
||||
async function deleteSegment(id: string) {
|
||||
if (!confirm('Delete this segment? This cannot be undone.')) return;
|
||||
try {
|
||||
const r = await fetch(`/api/segments/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||
if (r.ok) {
|
||||
segments = segments.filter(s => s.id !== id);
|
||||
if (selectedId === id) selectedId = null;
|
||||
updateMapSource();
|
||||
} else {
|
||||
const d = await r.json();
|
||||
alert(d.detail ?? 'Failed to delete');
|
||||
}
|
||||
} catch { alert('Could not reach server'); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||
style="border-color: var(--border)">
|
||||
<h1 class="text-lg font-bold text-white">Segments</h1>
|
||||
<a href="{base}segments/new/"
|
||||
class="px-3 py-1.5 rounded-lg text-sm bg-blue-600 hover:bg-blue-500 text-white transition-colors">
|
||||
+ New segment
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<div class="relative" style="height: 400px;">
|
||||
<div bind:this={mapEl} class="w-full h-full"></div>
|
||||
{#if loading}
|
||||
<div class="absolute top-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded">Loading…</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Segment list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if segments.length === 0 && !loading}
|
||||
<p class="text-zinc-500 text-sm text-center py-10">
|
||||
No segments in this area.<br/>
|
||||
<a href="{base}segments/new/" class="text-blue-400 hover:text-blue-300">Create the first one →</a>
|
||||
</p>
|
||||
{:else}
|
||||
<div class="divide-y" style="border-color: var(--border)">
|
||||
{#each segments as seg (seg.id)}
|
||||
<div
|
||||
class="px-4 py-3 cursor-pointer transition-colors"
|
||||
class:bg-zinc-800={selectedId === seg.id}
|
||||
style={selectedId !== seg.id ? 'background: transparent' : ''}
|
||||
on:click={() => selectedId = selectedId === seg.id ? null : seg.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={e => e.key === 'Enter' && (selectedId = selectedId === seg.id ? null : seg.id)}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if seg.sport}<span class="text-base">{sportIcon(seg.sport as any)}</span>{/if}
|
||||
<span class="text-sm font-medium text-white truncate">{seg.name}</span>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500 mt-0.5">
|
||||
{formatDistance(seg.distance_m)} · by {seg.created_by} · {formatDate(seg.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-zinc-600 hover:text-red-400 transition-colors shrink-0"
|
||||
on:click|stopPropagation={() => deleteSegment(seg.id)}
|
||||
title="Delete segment"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{#if selectedId === seg.id}
|
||||
<div class="mt-2">
|
||||
<a href="{base}segments/{seg.id}/"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
|
||||
View efforts →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user