193 lines
6.9 KiB
Svelte
193 lines
6.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
|
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
|
|
import { loadIndex } from '../lib/dataloader';
|
|
|
|
/** Render preview_coords as an SVG polyline path string. */
|
|
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
|
|
if (!coords || coords.length < 2) return '';
|
|
const lats = coords.map(c => c[0]);
|
|
const lons = coords.map(c => c[1]);
|
|
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
|
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
|
|
const latR = maxLat - minLat || 0.001;
|
|
const lonR = maxLon - minLon || 0.001;
|
|
const pad = 4;
|
|
const scaleX = (w - pad * 2) / lonR;
|
|
const scaleY = (h - pad * 2) / latR;
|
|
const scale = Math.min(scaleX, scaleY);
|
|
const offX = pad + (w - pad * 2 - lonR * scale) / 2;
|
|
const offY = pad + (h - pad * 2 - latR * scale) / 2;
|
|
return coords
|
|
.map(([lat, lon], i) => {
|
|
const x = (lon - minLon) * scale + offX;
|
|
const y = h - ((lat - minLat) * scale + offY); // flip: SVG y↓, lat↑
|
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
|
})
|
|
.join(' ');
|
|
}
|
|
|
|
const PAGE_SIZE = 60;
|
|
|
|
let all: ActivitySummary[] = [];
|
|
let sport: Sport | 'all' = 'all';
|
|
let shown = PAGE_SIZE;
|
|
let loading = true;
|
|
let error = '';
|
|
let mounted = false;
|
|
|
|
$: filtered = sport === 'all' ? all : all.filter(a => a.sport === sport);
|
|
$: visible = filtered.slice(0, shown);
|
|
$: hasMore = shown < filtered.length;
|
|
|
|
$: if (sport) shown = PAGE_SIZE; // reset pagination on filter change
|
|
|
|
$: if (mounted) {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (sport === 'all') params.delete('sport'); else params.set('sport', sport);
|
|
const qs = params.toString();
|
|
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
|
|
}
|
|
|
|
onMount(async () => {
|
|
sport = (new URLSearchParams(window.location.search).get('sport') as Sport | 'all') ?? 'all';
|
|
mounted = true;
|
|
try {
|
|
const index = await loadIndex(import.meta.env.BASE_URL);
|
|
all = index.activities.filter(a => a.privacy !== 'private');
|
|
} catch (e: any) {
|
|
error = e.message;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
});
|
|
|
|
const sports: Array<{ value: Sport | 'all'; label: string }> = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'cycling', label: '🚴 Cycling' },
|
|
{ value: 'running', label: '🏃 Running' },
|
|
{ value: 'hiking', label: '🥾 Hiking' },
|
|
{ value: 'walking', label: '🚶 Walking' },
|
|
{ value: 'swimming', label: '🏊 Swimming' },
|
|
{ value: 'skiing', label: '⛷️ Skiing' },
|
|
{ value: 'other', label: '⚡ Other' },
|
|
];
|
|
</script>
|
|
|
|
<!-- Filter bar -->
|
|
<div class="flex gap-2 mb-6 flex-wrap">
|
|
{#each sports as s}
|
|
<button
|
|
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
|
|
class:border-zinc-700={sport !== s.value}
|
|
class:text-zinc-400={sport !== s.value}
|
|
class:border-[--accent]={sport === s.value}
|
|
class:text-white={sport === s.value}
|
|
style={sport === s.value ? 'background:var(--accent-dim)' : ''}
|
|
on:click={() => sport = s.value}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
{/each}
|
|
{#if all.length > 0}
|
|
<span class="ml-auto text-sm text-zinc-500 self-center">
|
|
{filtered.length} {filtered.length === 1 ? 'activity' : 'activities'}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{#each Array(12) as _}
|
|
<div class="h-36 rounded-xl bg-zinc-800 animate-pulse"></div>
|
|
{/each}
|
|
</div>
|
|
{:else if error}
|
|
<p class="text-red-400 text-center py-12">Could not load activities: {error}</p>
|
|
{:else if filtered.length === 0}
|
|
<p class="text-zinc-500 text-center py-12">No activities found.</p>
|
|
{:else}
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{#each visible as a (a.id)}
|
|
<a
|
|
href={`${import.meta.env.BASE_URL}activity/${a.id}/`}
|
|
class="block rounded-xl bg-zinc-900 border border-zinc-800 p-4 hover:border-zinc-600 hover:bg-zinc-800/80 transition-all group"
|
|
>
|
|
<!-- header -->
|
|
<div class="flex items-start justify-between gap-2 mb-3">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-xs text-zinc-500 mb-0.5">{formatDate(a.started_at)}</p>
|
|
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
|
|
{a.title}
|
|
</h3>
|
|
</div>
|
|
<span
|
|
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
|
style="background:{sportColor(a.sport)}22; color:{sportColor(a.sport)}"
|
|
>
|
|
{sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- track thumbnail -->
|
|
{#if a.preview_coords}
|
|
<svg viewBox="0 0 120 70" class="w-full mt-2 mb-3 rounded overflow-hidden bg-zinc-800/60" style="height:70px">
|
|
<path
|
|
d={trackPath(a.preview_coords, 120, 70)}
|
|
fill="none"
|
|
stroke={sportColor(a.sport)}
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
opacity="0.9"
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
|
|
<!-- stats row -->
|
|
<div class="grid grid-cols-3 gap-2 text-center">
|
|
<div>
|
|
<p class="text-lg font-bold text-white">{formatDistance(a.distance_m)}</p>
|
|
<p class="text-xs text-zinc-500">Distance</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-lg font-bold text-white">{formatDuration(a.moving_time_s ?? a.duration_s)}</p>
|
|
<p class="text-xs text-zinc-500">Moving time</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-lg font-bold text-white">{formatElevation(a.elevation_gain_m)}</p>
|
|
<p class="text-xs text-zinc-500">Elevation</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- secondary stats -->
|
|
{#if a.avg_speed_kmh || a.avg_hr_bpm}
|
|
<div class="flex gap-4 mt-3 pt-3 border-t border-zinc-800 text-xs text-zinc-400">
|
|
{#if a.avg_speed_kmh}
|
|
<span>⚡ {a.avg_speed_kmh.toFixed(1)} km/h</span>
|
|
{/if}
|
|
{#if a.avg_hr_bpm}
|
|
<span>♥ {a.avg_hr_bpm} bpm</span>
|
|
{/if}
|
|
{#if a.avg_cadence_rpm}
|
|
<span>↻ {a.avg_cadence_rpm} rpm</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if hasMore}
|
|
<div class="text-center mt-8">
|
|
<button
|
|
class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors text-sm"
|
|
on:click={() => shown += PAGE_SIZE}
|
|
>
|
|
Load more ({filtered.length - shown} remaining)
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|