608 lines
24 KiB
Svelte
608 lines
24 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
|
import { formatDistance, formatDuration, formatElevation, formatDate, formatSpeed, formatPace, isPaceSport, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
|
|
import { loadIndexPaged, loadShardActivities, loadCombinedFeed } from '../lib/dataloader';
|
|
import FeedMapView from './FeedMapView.svelte';
|
|
|
|
/** 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(' ');
|
|
}
|
|
|
|
/** Base URL of the site (passed from Astro). */
|
|
export let base: string = '/';
|
|
/** When set, load this index URL instead of the root (for per-user profile pages). */
|
|
export let profileIndexUrl: string = '';
|
|
/** When set, only show activities from this handle. */
|
|
export let filterHandle: string = '';
|
|
|
|
const PAGE_SIZE = 60;
|
|
|
|
let all: ActivitySummary[] = [];
|
|
let sport: Sport | 'all' = 'all';
|
|
let datePre = 'all';
|
|
let dateFrom = '';
|
|
let dateTo = '';
|
|
let customFrom = '';
|
|
let customTo = '';
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
let query = '';
|
|
let shown = PAGE_SIZE;
|
|
let loading = true;
|
|
let loadingMore = false;
|
|
let error = '';
|
|
let viewMode: 'list' | 'map' = 'list';
|
|
let mounted = false;
|
|
let pendingShards: string[] = [];
|
|
/** Grand total from feed.json — shows instance-wide count even before all pages are loaded. */
|
|
let totalActivities = 0;
|
|
/** Logged-in handle — resolved async via bincio:me event. */
|
|
let me: string = '';
|
|
|
|
// ── Bulk / select mode ────────────────────────────────────────────────────
|
|
let selectMode = false;
|
|
let selected: Set<string> = new Set();
|
|
let hiddenIds: Set<string> = new Set();
|
|
let bulkWorking = false;
|
|
|
|
async function _loadMerges() {
|
|
try {
|
|
const r = await fetch('/api/merges', { credentials: 'include' });
|
|
if (r.ok) {
|
|
const data = await r.json();
|
|
hiddenIds = new Set(data.hidden ?? []);
|
|
}
|
|
} catch { /* non-critical */ }
|
|
}
|
|
|
|
function _toggleSelect(id: string) {
|
|
const s = new Set(selected);
|
|
s.has(id) ? s.delete(id) : s.add(id);
|
|
selected = s;
|
|
}
|
|
|
|
function _exitSelect() {
|
|
selectMode = false;
|
|
selected = new Set();
|
|
}
|
|
|
|
async function _deleteSelected() {
|
|
const ids = [...selected];
|
|
if (!confirm(`Delete ${ids.length} activit${ids.length === 1 ? 'y' : 'ies'}? This cannot be undone.`)) return;
|
|
bulkWorking = true;
|
|
for (const id of ids) {
|
|
try {
|
|
const r = await fetch(`/api/activity/${id}`, { method: 'DELETE', credentials: 'include' });
|
|
if (r.ok) all = all.filter(a => a.id !== id);
|
|
} catch { /* continue */ }
|
|
}
|
|
bulkWorking = false;
|
|
_exitSelect();
|
|
}
|
|
|
|
async function _mergeSelected() {
|
|
const ids = [...selected];
|
|
if (ids.length < 2) return;
|
|
bulkWorking = true;
|
|
try {
|
|
const r = await fetch('/api/merge', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ activity_ids: ids }),
|
|
});
|
|
const data = await r.json();
|
|
if (r.ok) {
|
|
for (const hid of data.hidden ?? []) hiddenIds.add(hid);
|
|
hiddenIds = new Set(hiddenIds);
|
|
_exitSelect();
|
|
} else {
|
|
alert(data.detail ?? 'Merge failed');
|
|
}
|
|
} catch {
|
|
alert('Merge failed');
|
|
} finally {
|
|
bulkWorking = false;
|
|
}
|
|
}
|
|
|
|
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
|
|
if (preset === 'all') return { dateFrom: '', dateTo: '' };
|
|
if (/^\d{4}$/.test(preset)) {
|
|
const y = parseInt(preset, 10);
|
|
return { dateFrom: `${y}-01-01T`, dateTo: `${y + 1}-01-01T` };
|
|
}
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
const now = new Date();
|
|
let d: Date;
|
|
if (preset === '7d') d = new Date(now.getTime() - 7 * 86_400_000);
|
|
else if (preset === '30d') d = new Date(now.getTime() - 30 * 86_400_000);
|
|
else { d = new Date(now); d.setMonth(d.getMonth() - 6); }
|
|
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T`, dateTo: '' };
|
|
}
|
|
|
|
// Filter out secondary activities hidden by a merge.
|
|
$: withMerge = all.filter(a => !hiddenIds.has(a.id));
|
|
|
|
// Show private activities only to their owner.
|
|
// On a profile page (filterHandle set): show unlisted if me === filterHandle.
|
|
// On the global feed: show unlisted only for the logged-in user's own activities.
|
|
$: isOwner = filterHandle !== '' && me === filterHandle;
|
|
$: withPrivacy = withMerge.filter(a => {
|
|
if (isUnlisted(a.privacy)) {
|
|
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
|
|
}
|
|
return true;
|
|
});
|
|
$: allYears = [...new Set(all.map(a => a.started_at?.slice(0, 4)).filter(Boolean) as string[])].sort().reverse();
|
|
$: dateFrom = (customFrom || customTo) ? customFrom : computeDateRange(datePre).dateFrom;
|
|
$: dateTo = (customFrom || customTo) ? (customTo ? customTo + '' : '') : computeDateRange(datePre).dateTo;
|
|
$: withDate = !dateFrom && !dateTo ? withPrivacy : withPrivacy.filter(a =>
|
|
(!dateFrom || a.started_at >= dateFrom) && (!dateTo || a.started_at < dateTo)
|
|
);
|
|
$: filtered = sport === 'all' ? withDate : withDate.filter(a => a.sport === sport);
|
|
$: withSearch = query.trim()
|
|
? filtered.filter(a => a.title?.toLowerCase().includes(query.trim().toLowerCase()))
|
|
: filtered;
|
|
$: visible = withSearch.slice(0, shown);
|
|
$: canShowMore = shown < withSearch.length;
|
|
$: hasMore = canShowMore || pendingShards.length > 0;
|
|
|
|
async function loadMore() {
|
|
if (canShowMore) {
|
|
shown += PAGE_SIZE;
|
|
return;
|
|
}
|
|
if (!pendingShards.length) return;
|
|
loadingMore = true;
|
|
try {
|
|
const url = pendingShards[0];
|
|
pendingShards = pendingShards.slice(1);
|
|
const fresh = await loadShardActivities(url);
|
|
const existing = new Map(all.map(a => [a.id, a]));
|
|
for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a);
|
|
all = [...existing.values()].sort((a, b) =>
|
|
(b.started_at ?? '').localeCompare(a.started_at ?? ''),
|
|
);
|
|
shown += PAGE_SIZE;
|
|
} catch {
|
|
// load failed — don't block the user
|
|
} finally {
|
|
loadingMore = false;
|
|
}
|
|
}
|
|
|
|
$: if (sport || datePre || query || customFrom || customTo) shown = PAGE_SIZE; // reset pagination on filter change
|
|
|
|
// Eager-load shards when a filter needs data not yet in memory.
|
|
let loadingAllShards = false;
|
|
|
|
function _yearFromShard(url: string): number | null {
|
|
const m = url.match(/(?:index|feed)-(\d{4})(?:-\d{2})?\.json$/);
|
|
return m ? parseInt(m[1], 10) : null;
|
|
}
|
|
|
|
// Returns [minYear, maxYear] for year-specific filters (year preset or custom
|
|
// date range). Returns null for open-ended filters (all, 7d, 30d, 6mo).
|
|
function _neededYearRange(pre: string, from: string, to: string): [number, number] | null {
|
|
if (from || to) {
|
|
const fy = from ? parseInt(from.slice(0, 4), 10) : 0;
|
|
const ty = to ? parseInt(to.slice(0, 4), 10) : 9999;
|
|
return [fy, ty];
|
|
}
|
|
if (/^\d{4}$/.test(pre)) { const y = parseInt(pre, 10); return [y, y]; }
|
|
return null;
|
|
}
|
|
|
|
$: {
|
|
const yr = _neededYearRange(datePre, customFrom, customTo);
|
|
const needEager = !!query.trim() || yr !== null;
|
|
if (needEager && pendingShards.length > 0 && !loadingAllShards) {
|
|
// Compute toLoad first — if empty (needed years already loaded, others remain),
|
|
// skip the async entirely to avoid an infinite reactive loop.
|
|
const toLoad = (yr && !query.trim())
|
|
? pendingShards.filter(url => { const y = _yearFromShard(url); return y !== null && y >= yr[0] && y <= yr[1]; })
|
|
: [...pendingShards];
|
|
if (toLoad.length > 0) {
|
|
loadingAllShards = true;
|
|
(async () => {
|
|
for (const url of toLoad) {
|
|
pendingShards = pendingShards.filter(u => u !== url);
|
|
try {
|
|
const fresh = await loadShardActivities(url);
|
|
const existing = new Map(all.map(a => [a.id, a]));
|
|
for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a);
|
|
all = [...existing.values()].sort((a, b) =>
|
|
(b.started_at ?? '').localeCompare(a.started_at ?? ''),
|
|
);
|
|
} catch { /* ignore — partial results still useful */ }
|
|
}
|
|
loadingAllShards = false;
|
|
})();
|
|
}
|
|
}
|
|
}
|
|
|
|
$: if (mounted) {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (sport === 'all') params.delete('sport'); else params.set('sport', sport);
|
|
if (datePre === 'all' || customFrom || customTo) params.delete('date'); else params.set('date', datePre);
|
|
if (!query.trim()) params.delete('q'); else params.set('q', query.trim());
|
|
if (!customFrom) params.delete('from'); else params.set('from', customFrom);
|
|
if (!customTo) params.delete('to'); else params.set('to', customTo);
|
|
const qs = params.toString();
|
|
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
|
|
}
|
|
|
|
onMount(async () => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
sport = (params.get('sport') as Sport | 'all') ?? 'all';
|
|
datePre = params.get('date') ?? 'all';
|
|
query = params.get('q') ?? '';
|
|
customFrom = params.get('from') ?? '';
|
|
customTo = params.get('to') ?? '';
|
|
mounted = true;
|
|
|
|
// Resolve the logged-in handle so we can show the owner their private activities.
|
|
if ((window as any).__bincioMe !== undefined) {
|
|
me = (window as any).__bincioMe;
|
|
_loadMerges();
|
|
} else {
|
|
window.addEventListener('bincio:me', (e: Event) => {
|
|
me = (e as CustomEvent).detail;
|
|
_loadMerges();
|
|
}, { once: true });
|
|
}
|
|
|
|
try {
|
|
const isGlobalFeed = !profileIndexUrl && !filterHandle;
|
|
if (isGlobalFeed) {
|
|
const combined = await loadCombinedFeed(base);
|
|
if (combined) {
|
|
all = combined.activities;
|
|
totalActivities = combined.totalActivities;
|
|
pendingShards = combined.pendingShards;
|
|
loading = false;
|
|
return;
|
|
}
|
|
}
|
|
const indexUrl = profileIndexUrl
|
|
? `${base}data/${profileIndexUrl}`
|
|
: `${base}data/index.json`;
|
|
const { index, pendingShards: pending } = await loadIndexPaged(base, indexUrl);
|
|
pendingShards = pending;
|
|
let activities = index.activities;
|
|
if (filterHandle && !profileIndexUrl) {
|
|
activities = activities.filter(a => (a as any).handle === filterHandle);
|
|
}
|
|
all = activities;
|
|
} 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>
|
|
|
|
<!-- Search + date range -->
|
|
<div class="flex flex-col md:flex-row gap-2 mb-4">
|
|
<input
|
|
type="search"
|
|
placeholder="Search activities…"
|
|
bind:value={query}
|
|
class="flex-1 px-4 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-zinc-500 transition-colors"
|
|
/>
|
|
<div class="flex gap-2 items-center">
|
|
<div class="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 focus-within:border-zinc-500 transition-colors">
|
|
<span class="text-xs text-zinc-500 whitespace-nowrap select-none">From</span>
|
|
<input
|
|
type="date"
|
|
bind:value={customFrom}
|
|
max={customTo || today}
|
|
on:change={() => { datePre = 'all'; }}
|
|
class="bg-transparent text-white text-sm focus:outline-none [color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
<div class="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 focus-within:border-zinc-500 transition-colors">
|
|
<span class="text-xs text-zinc-500 whitespace-nowrap select-none">To</span>
|
|
<input
|
|
type="date"
|
|
bind:value={customTo}
|
|
min={customFrom}
|
|
max={today}
|
|
on:change={() => { datePre = 'all'; }}
|
|
class="bg-transparent text-white text-sm focus:outline-none [color-scheme:dark]"
|
|
/>
|
|
</div>
|
|
<div class="flex rounded-lg border border-zinc-700 overflow-hidden text-sm shrink-0">
|
|
<button
|
|
class="px-3 py-2 transition-colors"
|
|
class:bg-zinc-800={viewMode === 'list'}
|
|
class:text-white={viewMode === 'list'}
|
|
class:text-zinc-400={viewMode !== 'list'}
|
|
on:click={() => viewMode = 'list'}
|
|
>List</button>
|
|
<button
|
|
class="px-3 py-2 border-l border-zinc-700 transition-colors"
|
|
class:bg-zinc-800={viewMode === 'map'}
|
|
class:text-white={viewMode === 'map'}
|
|
class:text-zinc-400={viewMode !== 'map'}
|
|
on:click={() => viewMode = 'map'}
|
|
>Map</button>
|
|
</div>
|
|
{#if isOwner}
|
|
<button
|
|
class="px-3 py-2 rounded-lg border text-sm transition-colors shrink-0"
|
|
class:border-zinc-700={!selectMode}
|
|
class:text-zinc-400={!selectMode}
|
|
class:border-[--accent]={selectMode}
|
|
class:text-white={selectMode}
|
|
style={selectMode ? 'background:var(--accent-dim)' : ''}
|
|
on:click={() => { if (selectMode) _exitSelect(); else selectMode = true; }}
|
|
>{selectMode ? 'Cancel' : 'Select'}</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sport filter bar -->
|
|
<div class="flex gap-2 mb-3 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">
|
|
{#if totalActivities > withSearch.length}
|
|
{withSearch.length} of {totalActivities} activities
|
|
{:else}
|
|
{withSearch.length} {withSearch.length === 1 ? 'activity' : 'activities'}
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Date filter bar -->
|
|
<div class="flex gap-2 mb-6 flex-wrap">
|
|
{#each [{ value: 'all', label: 'All time' }, { value: '7d', label: '7 days' }, { value: '30d', label: '30 days' }, { value: '6mo', label: '6 months' }, ...allYears.map(y => ({ value: y, label: y }))] as d}
|
|
{@const isActive = datePre === d.value && !customFrom && !customTo}
|
|
<button
|
|
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
|
|
class:border-zinc-700={!isActive}
|
|
class:text-zinc-400={!isActive}
|
|
class:border-[--accent]={isActive}
|
|
class:text-white={isActive}
|
|
style={isActive ? 'background:var(--accent-dim)' : ''}
|
|
on:click={() => { datePre = d.value; customFrom = ''; customTo = ''; }}
|
|
>
|
|
{d.label}
|
|
</button>
|
|
{/each}
|
|
</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 viewMode === 'map'}
|
|
<FeedMapView activities={withSearch} base={base} />
|
|
{#if hasMore}
|
|
<div class="text-center mt-4">
|
|
<button
|
|
class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white disabled:opacity-40 transition-colors text-sm"
|
|
disabled={loadingMore}
|
|
on:click={loadMore}
|
|
>
|
|
{#if loadingMore}
|
|
Loading…
|
|
{:else if canShowMore}
|
|
Load more ({filtered.length - shown} remaining)
|
|
{:else}
|
|
Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'period' : 'periods'})
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{:else if withSearch.length === 0}
|
|
<p class="text-zinc-500 text-center py-12">
|
|
{#if loadingAllShards}Loading…{:else if query.trim()}No activities match "{query.trim()}".{:else}No activities found.{/if}
|
|
</p>
|
|
{:else}
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{#each visible as a (a.id)}
|
|
{@const isSelected = selected.has(a.id)}
|
|
<!-- relative + isolate so the stretched activity link stays below the handle link -->
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<div
|
|
class="relative rounded-xl bg-zinc-900 border p-4 transition-all group {isSelected ? 'border-[--accent]' : 'border-zinc-800'} {selectMode ? 'cursor-pointer' : 'hover:border-zinc-600 hover:bg-zinc-800/80'}"
|
|
style={isSelected ? 'background:var(--accent-dim)' : ''}
|
|
on:click={selectMode ? () => _toggleSelect(a.id) : undefined}
|
|
>
|
|
<!-- 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)}{#if a.handle} · <a
|
|
href="{import.meta.env.BASE_URL}u/{a.handle}/"
|
|
class="relative z-10 hover:text-zinc-300 transition-colors"
|
|
>@{a.handle}</a>{/if}
|
|
</p>
|
|
<!-- stretched link covers the whole card; sits below the handle link -->
|
|
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors flex items-center gap-1.5">
|
|
{#if isUnlisted(a.privacy)}
|
|
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
|
|
{/if}
|
|
{#if selectMode}
|
|
<span class="truncate">{a.title}</span>
|
|
{:else}
|
|
<a
|
|
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
|
|
class="before:absolute before:inset-0 before:content-[''] truncate"
|
|
on:click={() => { try { sessionStorage.setItem(`bincio:activity:${a.id}`, JSON.stringify(filterHandle && !a.handle ? { ...a, handle: filterHandle } : a)); } catch {} }}
|
|
>{a.title}</a>
|
|
{/if}
|
|
</h3>
|
|
</div>
|
|
{#if selectMode}
|
|
<div
|
|
class="shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors"
|
|
class:border-zinc-600={!isSelected}
|
|
class:border-[--accent]={isSelected}
|
|
style={isSelected ? 'background:var(--accent)' : ''}
|
|
>
|
|
{#if isSelected}<span class="text-[10px] text-white leading-none">✓</span>{/if}
|
|
</div>
|
|
{:else}
|
|
<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>
|
|
{/if}
|
|
</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>⚡ {isPaceSport(a.sport) ? formatPace(a.avg_speed_kmh) : formatSpeed(a.avg_speed_kmh)}</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}
|
|
</div>
|
|
{/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 disabled:opacity-40 transition-colors text-sm"
|
|
disabled={loadingMore}
|
|
on:click={loadMore}
|
|
>
|
|
{#if loadingMore}
|
|
Loading…
|
|
{:else if canShowMore}
|
|
Load more ({filtered.length - shown} remaining)
|
|
{:else}
|
|
Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'period' : 'periods'})
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Bulk action bar — fixed at bottom when select mode is active -->
|
|
{#if selectMode}
|
|
<div class="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-3 px-4 py-4 bg-zinc-950/95 backdrop-blur border-t border-zinc-800">
|
|
<span class="text-sm text-zinc-400 mr-2">
|
|
{selected.size} selected
|
|
</span>
|
|
<button
|
|
class="px-4 py-2 rounded-lg border border-red-700 text-red-400 hover:bg-red-900/30 disabled:opacity-40 transition-colors text-sm"
|
|
disabled={selected.size === 0 || bulkWorking}
|
|
on:click={_deleteSelected}
|
|
>
|
|
Delete ({selected.size})
|
|
</button>
|
|
<button
|
|
class="px-4 py-2 rounded-lg border border-zinc-600 text-zinc-300 hover:bg-zinc-800 disabled:opacity-40 transition-colors text-sm"
|
|
disabled={selected.size < 2 || bulkWorking}
|
|
on:click={_mergeSelected}
|
|
>
|
|
Merge ({selected.size})
|
|
</button>
|
|
<button
|
|
class="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-500 hover:text-zinc-300 transition-colors text-sm"
|
|
on:click={_exitSelect}
|
|
>
|
|
Cancel
|
|
</button>
|
|
{#if bulkWorking}
|
|
<span class="text-xs text-zinc-500">Working…</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|