Segments phase 2: /segments/ browse page, /segments/new/ creation flow, activity detail shortcut
This commit is contained in:
@@ -329,6 +329,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create segment shortcut -->
|
||||
{#if trackUrl}
|
||||
<div class="mt-3 flex justify-end">
|
||||
<a
|
||||
href="{base}segments/new/?activity={activity.id}"
|
||||
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors"
|
||||
>+ Create segment from this activity</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Charts -->
|
||||
{#if error}
|
||||
<p class="text-red-400 text-sm mt-4">{error}</p>
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { ActivitySummary } from '../lib/types';
|
||||
import { loadIndexPaged, loadActivity, loadTimeseries } from '../lib/dataloader';
|
||||
import { formatDistance, formatDate, sportIcon } from '../lib/format';
|
||||
|
||||
export let base: string = '/';
|
||||
|
||||
// ── Activity picker ───────────────────────────────────────────────────────
|
||||
let activities: ActivitySummary[] = [];
|
||||
let pendingShards: string[] = [];
|
||||
let loadingActivities = true;
|
||||
let loadingMore = false;
|
||||
let searchQuery = '';
|
||||
let selectedActivity: ActivitySummary | null = null;
|
||||
|
||||
// ── Track data ────────────────────────────────────────────────────────────
|
||||
let gpsPoints: [number, number][] = []; // [lat, lon]
|
||||
let startIdx = 0;
|
||||
let endIdx = 0;
|
||||
let loadingTrack = false;
|
||||
let trackError = '';
|
||||
|
||||
// ── Segment form ──────────────────────────────────────────────────────────
|
||||
let segName = '';
|
||||
let segSport = '';
|
||||
let saving = false;
|
||||
let saveError = '';
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────────────────────
|
||||
let mapEl: HTMLDivElement;
|
||||
let map: any;
|
||||
let mapReady = false;
|
||||
|
||||
const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron';
|
||||
const DIM_COLOR = '#71717a';
|
||||
const SEL_COLOR = '#3b82f6';
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
$: filteredActivities = activities.filter(a =>
|
||||
a.preview_coords != null && (
|
||||
!searchQuery ||
|
||||
(a.title ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
formatDate(a.started_at).toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
$: maxIdx = Math.max(0, gpsPoints.length - 1);
|
||||
$: startPct = maxIdx > 0 ? (startIdx / maxIdx) * 100 : 0;
|
||||
$: endPct = maxIdx > 0 ? (endIdx / maxIdx) * 100 : 100;
|
||||
$: selectedPolyline = gpsPoints.slice(startIdx, endIdx + 1);
|
||||
$: selectedDistance = polylineDistance(selectedPolyline);
|
||||
$: canSave = segName.trim().length > 0 && selectedPolyline.length >= 2 && !saving;
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
onMount(async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const preId = params.get('activity');
|
||||
try {
|
||||
const { index, pendingShards: ps } = await loadIndexPaged(base);
|
||||
activities = index.activities;
|
||||
pendingShards = ps;
|
||||
if (preId) {
|
||||
const found = activities.find(a => a.id === preId);
|
||||
if (found) await selectActivity(found);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
loadingActivities = false;
|
||||
});
|
||||
|
||||
onDestroy(() => map?.remove());
|
||||
|
||||
// ── Activity selection ────────────────────────────────────────────────────
|
||||
async function selectActivity(a: ActivitySummary) {
|
||||
selectedActivity = a;
|
||||
segSport = a.sport ?? '';
|
||||
loadingTrack = true;
|
||||
trackError = '';
|
||||
gpsPoints = [];
|
||||
mapReady = false;
|
||||
map?.remove();
|
||||
|
||||
try {
|
||||
const detail = await loadActivity(a.id, a.detail_url ?? '', base);
|
||||
if (!detail?.timeseries_url) { trackError = 'No GPS data for this activity.'; loadingTrack = false; return; }
|
||||
const ts = await loadTimeseries(detail.timeseries_url, a.detail_url ?? '', base);
|
||||
if (!ts) { trackError = 'Could not load GPS track.'; loadingTrack = false; return; }
|
||||
|
||||
const pts: [number, number][] = [];
|
||||
const lats = ts.lat ?? [], lons = ts.lon ?? [];
|
||||
for (let i = 0; i < lats.length; i++) {
|
||||
if (lats[i] != null && lons[i] != null) pts.push([lats[i]!, lons[i]!]);
|
||||
}
|
||||
if (pts.length < 2) { trackError = 'Not enough GPS points.'; loadingTrack = false; return; }
|
||||
|
||||
gpsPoints = pts;
|
||||
startIdx = 0;
|
||||
endIdx = pts.length - 1;
|
||||
loadingTrack = false;
|
||||
// Map init happens after the DOM updates (bind:this needs the element)
|
||||
setTimeout(initMap, 0);
|
||||
} catch {
|
||||
trackError = 'Failed to load track.';
|
||||
loadingTrack = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreActivities() {
|
||||
if (!pendingShards.length || loadingMore) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
const url = pendingShards[0];
|
||||
pendingShards = pendingShards.slice(1);
|
||||
const r = await fetch(url);
|
||||
const shard = await r.json();
|
||||
const shardBase = url.substring(0, url.lastIndexOf('/') + 1);
|
||||
const newActs = (shard.activities ?? []).map((a: ActivitySummary) => ({
|
||||
...a,
|
||||
detail_url: a.detail_url?.startsWith('http') ? a.detail_url : shardBase + a.detail_url,
|
||||
track_url: a.track_url?.startsWith('http') ? a.track_url : shardBase + a.track_url,
|
||||
}));
|
||||
activities = [...activities, ...newActs];
|
||||
} catch { /* ignore */ }
|
||||
loadingMore = false;
|
||||
}
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────────────────────
|
||||
function toLine(pts: [number, number][]) {
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'LineString' as const, coordinates: pts.map(([lat, lon]) => [lon, lat]) },
|
||||
};
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (!mapEl || !gpsPoints.length) return;
|
||||
const lats = gpsPoints.map(p => p[0]);
|
||||
const lons = gpsPoints.map(p => p[1]);
|
||||
|
||||
map = new maplibregl.Map({
|
||||
container: mapEl,
|
||||
style: TILE_STYLE,
|
||||
bounds: [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
|
||||
fitBoundsOptions: { padding: 40 },
|
||||
attributionControl: false,
|
||||
});
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('full', { type: 'geojson', data: toLine(gpsPoints) });
|
||||
map.addSource('selected', { type: 'geojson', data: toLine(selectedPolyline) });
|
||||
|
||||
map.addLayer({ id: 'full-line', type: 'line', source: 'full',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': DIM_COLOR, 'line-width': 2.5 } });
|
||||
|
||||
map.addLayer({ id: 'sel-line', type: 'line', source: 'selected',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': SEL_COLOR, 'line-width': 4.5 } });
|
||||
|
||||
mapReady = true;
|
||||
});
|
||||
}
|
||||
|
||||
$: if (mapReady && map?.getSource('selected')) {
|
||||
map.getSource('selected').setData(toLine(selectedPolyline));
|
||||
}
|
||||
|
||||
// ── Slider guards ─────────────────────────────────────────────────────────
|
||||
function onStartInput() { if (startIdx >= endIdx) startIdx = Math.max(0, endIdx - 1); }
|
||||
function onEndInput() { if (endIdx <= startIdx) endIdx = Math.min(maxIdx, startIdx + 1); }
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
saving = true; saveError = '';
|
||||
try {
|
||||
const r = await fetch('/api/segments', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: segName.trim(),
|
||||
sport: segSport || null,
|
||||
polyline: selectedPolyline,
|
||||
distance_m: selectedDistance,
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) { window.location.href = `${base}segments/`; }
|
||||
else { saveError = d.detail ?? 'Failed to save segment'; saving = false; }
|
||||
} catch {
|
||||
saveError = 'Could not reach server';
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function polylineDistance(pts: [number, number][]): number {
|
||||
let d = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const [lat1, lon1] = pts[i-1], [lat2, lon2] = pts[i];
|
||||
const R = 6371000;
|
||||
const φ1 = lat1 * Math.PI/180, φ2 = lat2 * Math.PI/180;
|
||||
const dφ = (lat2-lat1)*Math.PI/180, dλ = (lon2-lon1)*Math.PI/180;
|
||||
const a = Math.sin(dφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(dλ/2)**2;
|
||||
d += 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
}
|
||||
return d;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="{base}segments/" class="text-zinc-500 hover:text-white transition-colors text-sm">← Segments</a>
|
||||
<h1 class="text-xl font-bold text-white">New Segment</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 1: Activity picker ─────────────────────────────────────────── -->
|
||||
{#if !selectedActivity}
|
||||
<p class="text-zinc-400 text-sm mb-4">Pick an activity to crop the segment from.</p>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search by title or date…"
|
||||
class="w-full mb-4 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 text-sm"
|
||||
/>
|
||||
|
||||
{#if loadingActivities}
|
||||
<p class="text-zinc-500 text-sm">Loading activities…</p>
|
||||
{:else if filteredActivities.length === 0}
|
||||
<p class="text-zinc-500 text-sm">No GPS activities found{searchQuery ? ' matching your search' : ''}.</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-zinc-800 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
{#each filteredActivities.slice(0, 100) as a (a.id)}
|
||||
<button
|
||||
class="w-full text-left px-4 py-3 hover:bg-zinc-800 transition-colors flex items-center gap-3"
|
||||
on:click={() => selectActivity(a)}
|
||||
>
|
||||
<span class="text-lg shrink-0">{sportIcon(a.sport as any)}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-white truncate">{a.title ?? 'Untitled'}</p>
|
||||
<p class="text-xs text-zinc-500">{formatDate(a.started_at)} · {formatDistance(a.distance_m)}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filteredActivities.length > 100}
|
||||
<p class="text-zinc-600 text-xs mt-2">Showing first 100 results — refine your search to narrow down.</p>
|
||||
{/if}
|
||||
|
||||
{#if pendingShards.length > 0}
|
||||
<button
|
||||
class="mt-4 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
on:click={loadMoreActivities}
|
||||
disabled={loadingMore}
|
||||
>{loadingMore ? 'Loading…' : `Load older activities (${pendingShards.length} more year${pendingShards.length > 1 ? 's' : ''})`}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ── Step 2: Crop + form ──────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
<!-- Activity label + change button -->
|
||||
<div class="flex items-center gap-3 mb-4 p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<span class="text-lg">{sportIcon(selectedActivity.sport as any)}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white truncate">{selectedActivity.title ?? 'Untitled'}</p>
|
||||
<p class="text-xs text-zinc-500">{formatDate(selectedActivity.started_at)}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-zinc-400 hover:text-white transition-colors shrink-0"
|
||||
on:click={() => { selectedActivity = null; gpsPoints = []; map?.remove(); mapReady = false; }}
|
||||
>Change</button>
|
||||
</div>
|
||||
|
||||
{#if loadingTrack}
|
||||
<div class="h-64 flex items-center justify-center text-zinc-500 text-sm">Loading track…</div>
|
||||
{:else if trackError}
|
||||
<div class="h-32 flex items-center justify-center text-red-400 text-sm">{trackError}</div>
|
||||
{:else}
|
||||
<!-- Map -->
|
||||
<div class="rounded-xl overflow-hidden mb-4" style="height: 320px;">
|
||||
<div bind:this={mapEl} class="w-full h-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dual-range slider -->
|
||||
<div class="mb-5">
|
||||
<p class="text-xs text-zinc-500 mb-2">Drag the handles to set the segment start and end</p>
|
||||
<div class="range-wrap">
|
||||
<div class="range-track"></div>
|
||||
<div class="range-fill" style="left:{startPct}%; right:{100-endPct}%"></div>
|
||||
<input type="range" min={0} max={maxIdx} step={1}
|
||||
bind:value={startIdx} on:input={onStartInput} />
|
||||
<input type="range" min={0} max={maxIdx} step={1}
|
||||
bind:value={endIdx} on:input={onEndInput} />
|
||||
</div>
|
||||
<p class="text-xs text-zinc-400 mt-2">
|
||||
Selected: <span class="text-white font-medium">{formatDistance(selectedDistance)}</span>
|
||||
({selectedPolyline.length} points)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={segName}
|
||||
placeholder="Segment name…"
|
||||
maxlength="80"
|
||||
class="flex-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 text-sm"
|
||||
/>
|
||||
<select
|
||||
bind:value={segSport}
|
||||
class="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-blue-500 text-sm"
|
||||
>
|
||||
<option value="">Any sport</option>
|
||||
<option value="cycling">Cycling</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="skiing">Skiing</option>
|
||||
<option value="hiking">Hiking</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if saveError}
|
||||
<p class="text-red-400 text-sm mb-3">{saveError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click={save}
|
||||
disabled={!canSave}
|
||||
class="w-full py-2.5 rounded-lg text-sm font-medium transition-colors
|
||||
bg-blue-600 hover:bg-blue-500 text-white
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>{saving ? 'Saving…' : 'Save segment'}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.range-wrap {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.range-track {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #3f3f46;
|
||||
}
|
||||
.range-fill {
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #3b82f6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.range-wrap input[type=range] {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
.range-wrap input[type=range]::-webkit-slider-thumb {
|
||||
pointer-events: all;
|
||||
width: 20px; height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid #3b82f6;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
.range-wrap input[type=range]::-moz-range-thumb {
|
||||
pointer-events: all;
|
||||
width: 20px; height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid #3b82f6;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -220,6 +220,7 @@ try {
|
||||
{mobileApp && (
|
||||
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Convert</a>
|
||||
)}
|
||||
<a href={`${baseUrl}segments/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Segments</a>
|
||||
<a id="nav-about" href={`${baseUrl}about/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">About</a>
|
||||
{wikiUrl && (
|
||||
<a id="nav-wiki" href={wikiUrl} style="display:none"
|
||||
@@ -290,6 +291,9 @@ try {
|
||||
<div id="nav-menu" class="hidden border-t sm:hidden"
|
||||
style="border-color: var(--border); background: var(--bg-card)">
|
||||
<div class="max-w-7xl mx-auto px-4 py-2 flex flex-col">
|
||||
<a href={`${baseUrl}segments/`}
|
||||
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
style="color: var(--text-4)">Segments</a>
|
||||
<a href={`${baseUrl}settings/`}
|
||||
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
style="color: var(--text-4)">Settings</a>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import SegmentsView from '../../components/SegmentsView.svelte';
|
||||
---
|
||||
<Base title="Segments — BincioActivity">
|
||||
<div class="flex flex-col" style="height: calc(100vh - 48px);">
|
||||
<SegmentsView base={import.meta.env.BASE_URL} client:only="svelte" />
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import Base from '../../../layouts/Base.astro';
|
||||
import SegmentCreate from '../../../components/SegmentCreate.svelte';
|
||||
---
|
||||
<Base title="New Segment — BincioActivity">
|
||||
<SegmentCreate base={import.meta.env.BASE_URL} client:only="svelte" />
|
||||
</Base>
|
||||
Reference in New Issue
Block a user