Files
bincio-activity/site/src/components/ActivityFeed.svelte
T
2026-03-30 20:27:34 +02:00

194 lines
7.0 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';
/** 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 res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const index: BASIndex = await res.json();
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}