feat: bulk delete + merge activities in feed

- Select mode in ActivityFeed: toggle with Select button (logged-in only),
  cards become clickable with checkmark indicator, action bar fixed at bottom
- Bulk delete: calls existing DELETE /api/activity/{id} for each selected,
  removes from local feed state immediately
- Bulk merge: POST /api/merge sorts by started_at (earliest = primary),
  sums distance/duration/elevation, weighted-averages HR/power, concatenates
  geojson and timeseries; backs up originals to _merge_backup/ for recovery
- GET /api/merges returns per-user hidden list; feed filters secondaries
  client-side on load so static shards don't need a rebuild to hide them
- POST /api/unmerge/{id} restores primary from backup, unhides secondaries
- ActivityDetail: shows "Merged (N)" badge + Unmerge button for owners
- Fix: edit button now works from personal profile feed (handle was missing
  from year-shard activities; injected from filterHandle on sessionStorage write)
This commit is contained in:
Davide Scaini
2026-06-03 10:32:02 +02:00
parent 5287b98bc1
commit b781193d44
5 changed files with 462 additions and 14 deletions
+33
View File
@@ -36,6 +36,25 @@
pr_elapsed_s: number;
}
let segmentEfforts: SegmentEffortHit[] = [];
let unmergeWorking = false;
async function _unmerge() {
if (!confirm('Unmerge this activity? The original tracks will be restored and the secondary activities will reappear in your feed.')) return;
unmergeWorking = true;
try {
const r = await fetch(`/api/unmerge/${activity.id}`, { method: 'POST', credentials: 'include' });
if (r.ok) {
window.location.reload();
} else {
const d = await r.json();
alert(d.detail ?? 'Unmerge failed');
}
} catch {
alert('Unmerge failed');
} finally {
unmergeWorking = false;
}
}
// Local overrides applied immediately after a save (no re-fetch needed)
let localTitle = '';
@@ -381,6 +400,20 @@
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0"
>+ segment</a>
{/if}
{#if detail?.merged_ids?.length}
<span class="text-xs px-2 py-0.5 rounded border border-zinc-600 text-zinc-400 shrink-0">
Merged ({detail.merged_ids.length})
</span>
{#if editEnabled}
<button
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0 disabled:opacity-40"
disabled={unmergeWorking}
on:click={_unmerge}
>
{unmergeWorking ? 'Working…' : 'Unmerge'}
</button>
{/if}
{/if}
</div>
{#if descriptionHtml}
<div class="text-zinc-400 mt-2 text-sm leading-relaxed [&_img]:rounded-lg [&_img]:my-2 [&_p]:my-1 [&_a]:text-blue-400">
+153 -14
View File
@@ -59,6 +59,73 @@
/** 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)) {
@@ -74,11 +141,14 @@
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 = all.filter(a => {
$: withPrivacy = withMerge.filter(a => {
if (isUnlisted(a.privacy)) {
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
}
@@ -196,8 +266,12 @@
// 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; }, { once: true });
window.addEventListener('bincio:me', (e: Event) => {
me = (e as CustomEvent).detail;
_loadMerges();
}, { once: true });
}
try {
@@ -287,6 +361,17 @@
on:click={() => viewMode = 'map'}
>Map</button>
</div>
{#if me}
<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>
@@ -368,8 +453,15 @@
{: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 -->
<div class="relative rounded-xl bg-zinc-900 border border-zinc-800 p-4 hover:border-zinc-600 hover:bg-zinc-800/80 transition-all group">
<!-- 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">
@@ -384,19 +476,34 @@
{#if isUnlisted(a.privacy)}
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
{/if}
<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(a)); } catch {} }}
>{a.title}</a>
{#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>
<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 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 -->
@@ -466,3 +573,35 @@
</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}
+2
View File
@@ -130,6 +130,8 @@ export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'tr
source_file?: string | null;
download_disabled?: boolean;
custom: Record<string, unknown>;
/** IDs of secondary activities merged into this one (set by /api/merge). */
merged_ids?: string[] | null;
}
export interface Lap {