Files
bincio-activity/site/src/components/ActivityFeed.svelte
T

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}