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:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user