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:
@@ -0,0 +1,270 @@
|
|||||||
|
"""Merge and unmerge activity endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from bincio.serve import deps, tasks
|
||||||
|
from bincio.serve.db import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_MERGES_FILE = "_merges.json"
|
||||||
|
_BACKUP_DIR = "_merge_backup"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_merges(user_dir: Path) -> dict:
|
||||||
|
p = user_dir / _MERGES_FILE
|
||||||
|
if p.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
return {"hidden": [], "merges": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_merges(user_dir: Path, data: dict) -> None:
|
||||||
|
(user_dir / _MERGES_FILE).write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/merges")
|
||||||
|
async def get_merges(user: User = Depends(deps._require_auth)) -> JSONResponse:
|
||||||
|
user_dir = deps._get_data_dir() / user.handle
|
||||||
|
return JSONResponse(_read_merges(user_dir))
|
||||||
|
|
||||||
|
|
||||||
|
class MergeRequest(BaseModel):
|
||||||
|
activity_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/merge")
|
||||||
|
async def merge_activities(
|
||||||
|
body: MergeRequest,
|
||||||
|
user: User = Depends(deps._require_auth),
|
||||||
|
) -> JSONResponse:
|
||||||
|
if len(body.activity_ids) < 2:
|
||||||
|
raise HTTPException(400, "Need at least 2 activities to merge")
|
||||||
|
|
||||||
|
dd = deps._get_data_dir()
|
||||||
|
user_dir = dd / user.handle
|
||||||
|
acts_dir = user_dir / "activities"
|
||||||
|
|
||||||
|
activities: list[dict] = []
|
||||||
|
for aid in body.activity_ids:
|
||||||
|
deps._check_id(aid)
|
||||||
|
p = acts_dir / f"{aid}.json"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, f"Activity {aid} not found")
|
||||||
|
try:
|
||||||
|
a = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
raise HTTPException(500, f"Could not read activity {aid}")
|
||||||
|
a["_id"] = aid
|
||||||
|
activities.append(a)
|
||||||
|
|
||||||
|
sports = {a.get("sport") for a in activities}
|
||||||
|
if len(sports) > 1:
|
||||||
|
raise HTTPException(400, "Cannot merge activities of different sports")
|
||||||
|
|
||||||
|
activities.sort(key=lambda a: a.get("started_at", ""))
|
||||||
|
primary = activities[0]
|
||||||
|
secondaries = activities[1:]
|
||||||
|
primary_id = primary["_id"]
|
||||||
|
secondary_ids = [a["_id"] for a in secondaries]
|
||||||
|
|
||||||
|
def _sum(key: str) -> float | None:
|
||||||
|
vals = [a.get(key) for a in activities if a.get(key) is not None]
|
||||||
|
return sum(vals) if vals else None
|
||||||
|
|
||||||
|
def _wavg(key: str) -> float | None:
|
||||||
|
pairs = [(a.get(key), a.get("moving_time_s")) for a in activities]
|
||||||
|
pairs = [(v, w) for v, w in pairs if v is not None and w and w > 0]
|
||||||
|
if not pairs:
|
||||||
|
return None
|
||||||
|
total_w = sum(w for _, w in pairs)
|
||||||
|
return sum(v * w for v, w in pairs) / total_w if total_w > 0 else None
|
||||||
|
|
||||||
|
distance_m = _sum("distance_m")
|
||||||
|
duration_s = _sum("duration_s")
|
||||||
|
moving_time_s = _sum("moving_time_s")
|
||||||
|
elevation_gain_m = _sum("elevation_gain_m")
|
||||||
|
avg_speed_kmh = (distance_m / moving_time_s * 3.6) if distance_m and moving_time_s else None
|
||||||
|
avg_hr_bpm_val = _wavg("avg_hr_bpm")
|
||||||
|
avg_power_w_val = _wavg("avg_power_w")
|
||||||
|
|
||||||
|
backup_dir = user_dir / _BACKUP_DIR
|
||||||
|
backup_dir.mkdir(exist_ok=True)
|
||||||
|
for suffix in (".json", ".geojson", ".timeseries.json"):
|
||||||
|
src = acts_dir / f"{primary_id}{suffix}"
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy2(src, backup_dir / f"{primary_id}{suffix}.bak")
|
||||||
|
|
||||||
|
primary_data: dict[str, Any] = {k: v for k, v in primary.items() if not k.startswith("_")}
|
||||||
|
primary_data["distance_m"] = distance_m
|
||||||
|
primary_data["duration_s"] = duration_s
|
||||||
|
primary_data["moving_time_s"] = moving_time_s
|
||||||
|
primary_data["elevation_gain_m"] = elevation_gain_m
|
||||||
|
if avg_speed_kmh is not None:
|
||||||
|
primary_data["avg_speed_kmh"] = round(avg_speed_kmh, 3)
|
||||||
|
if avg_hr_bpm_val is not None:
|
||||||
|
primary_data["avg_hr_bpm"] = round(avg_hr_bpm_val)
|
||||||
|
if avg_power_w_val is not None:
|
||||||
|
primary_data["avg_power_w"] = round(avg_power_w_val)
|
||||||
|
primary_data["merged_ids"] = secondary_ids
|
||||||
|
|
||||||
|
(acts_dir / f"{primary_id}.json").write_text(
|
||||||
|
json.dumps(primary_data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
_merge_geojson(acts_dir, primary_id, secondary_ids)
|
||||||
|
_merge_timeseries(acts_dir, primary_id, secondary_ids, activities)
|
||||||
|
|
||||||
|
merges = _read_merges(user_dir)
|
||||||
|
for sid in secondary_ids:
|
||||||
|
if sid not in merges["hidden"]:
|
||||||
|
merges["hidden"].append(sid)
|
||||||
|
merges.setdefault("merges", {})[primary_id] = {
|
||||||
|
"secondary_ids": secondary_ids,
|
||||||
|
"merged_at": _now_iso(),
|
||||||
|
}
|
||||||
|
_write_merges(user_dir, merges)
|
||||||
|
|
||||||
|
from bincio.render.merge import merge_one
|
||||||
|
merge_one(user_dir, primary_id)
|
||||||
|
tasks._trigger_rebuild(user.handle)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "primary_id": primary_id, "hidden": secondary_ids})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/unmerge/{primary_id}")
|
||||||
|
async def unmerge_activity(
|
||||||
|
primary_id: str,
|
||||||
|
user: User = Depends(deps._require_auth),
|
||||||
|
) -> JSONResponse:
|
||||||
|
deps._check_id(primary_id)
|
||||||
|
dd = deps._get_data_dir()
|
||||||
|
user_dir = dd / user.handle
|
||||||
|
acts_dir = user_dir / "activities"
|
||||||
|
backup_dir = user_dir / _BACKUP_DIR
|
||||||
|
|
||||||
|
merges = _read_merges(user_dir)
|
||||||
|
merge_info = merges.get("merges", {}).get(primary_id)
|
||||||
|
if not merge_info:
|
||||||
|
raise HTTPException(404, "No merge record found for this activity")
|
||||||
|
|
||||||
|
secondary_ids: list[str] = merge_info.get("secondary_ids", [])
|
||||||
|
|
||||||
|
for suffix in (".json", ".geojson", ".timeseries.json"):
|
||||||
|
bak = backup_dir / f"{primary_id}{suffix}.bak"
|
||||||
|
if bak.exists():
|
||||||
|
shutil.copy2(bak, acts_dir / f"{primary_id}{suffix}")
|
||||||
|
bak.unlink()
|
||||||
|
|
||||||
|
merges["hidden"] = [h for h in merges.get("hidden", []) if h not in secondary_ids]
|
||||||
|
merges["merges"].pop(primary_id, None)
|
||||||
|
_write_merges(user_dir, merges)
|
||||||
|
|
||||||
|
from bincio.render.merge import merge_one
|
||||||
|
merge_one(user_dir, primary_id)
|
||||||
|
tasks._trigger_rebuild(user.handle)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "restored": secondary_ids})
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_geojson(acts_dir: Path, primary_id: str, secondary_ids: list[str]) -> None:
|
||||||
|
primary_path = acts_dir / f"{primary_id}.geojson"
|
||||||
|
if not primary_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
geo = json.loads(primary_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
features: list = list(geo.get("features", []))
|
||||||
|
for sid in secondary_ids:
|
||||||
|
p = acts_dir / f"{sid}.geojson"
|
||||||
|
if not p.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sec = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
features.extend(sec.get("features", []))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
geo["features"] = features
|
||||||
|
primary_path.write_text(json.dumps(geo, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_timeseries(
|
||||||
|
acts_dir: Path,
|
||||||
|
primary_id: str,
|
||||||
|
secondary_ids: list[str],
|
||||||
|
activities: list[dict],
|
||||||
|
) -> None:
|
||||||
|
primary_path = acts_dir / f"{primary_id}.timeseries.json"
|
||||||
|
if not primary_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
merged = json.loads(primary_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
merged = {k: list(v) if isinstance(v, list) else v for k, v in merged.items()}
|
||||||
|
|
||||||
|
_ARRAY_KEYS = ("lat", "lon", "elevation_m", "speed_kmh", "hr_bpm", "cadence_rpm", "power_w", "temperature_c")
|
||||||
|
|
||||||
|
for i, sid in enumerate(secondary_ids):
|
||||||
|
sec_path = acts_dir / f"{sid}.timeseries.json"
|
||||||
|
if not sec_path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sec = json.loads(sec_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
sec_t: list = sec.get("t") or []
|
||||||
|
if not sec_t:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
p_start = datetime.fromisoformat(
|
||||||
|
activities[0].get("started_at", "").replace("Z", "+00:00")
|
||||||
|
).timestamp()
|
||||||
|
s_start = datetime.fromisoformat(
|
||||||
|
activities[i + 1].get("started_at", "").replace("Z", "+00:00")
|
||||||
|
).timestamp()
|
||||||
|
offset = s_start - p_start
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
cur_t: list = merged.get("t") or []
|
||||||
|
offset = (cur_t[-1] + 1) if cur_t else 0
|
||||||
|
|
||||||
|
adjusted_t = [offset + t for t in sec_t]
|
||||||
|
primary_len = len(merged.get("t") or [])
|
||||||
|
|
||||||
|
if merged.get("t") is None:
|
||||||
|
merged["t"] = []
|
||||||
|
merged["t"].extend(adjusted_t)
|
||||||
|
|
||||||
|
for key in _ARRAY_KEYS:
|
||||||
|
sec_vals = sec.get(key)
|
||||||
|
if sec_vals is None:
|
||||||
|
if merged.get(key) is not None:
|
||||||
|
merged[key] = merged[key] + [None] * len(sec_t)
|
||||||
|
else:
|
||||||
|
if merged.get(key) is None:
|
||||||
|
merged[key] = [None] * primary_len + list(sec_vals)
|
||||||
|
else:
|
||||||
|
merged[key] = merged[key] + list(sec_vals)
|
||||||
|
|
||||||
|
primary_path.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8")
|
||||||
@@ -20,12 +20,14 @@ from bincio.serve.routers import (
|
|||||||
activities,
|
activities,
|
||||||
admin,
|
admin,
|
||||||
auth,
|
auth,
|
||||||
|
budget,
|
||||||
download,
|
download,
|
||||||
feed,
|
feed,
|
||||||
garmin,
|
garmin,
|
||||||
gear,
|
gear,
|
||||||
ideas,
|
ideas,
|
||||||
me,
|
me,
|
||||||
|
merge,
|
||||||
ogimage,
|
ogimage,
|
||||||
segments,
|
segments,
|
||||||
strava,
|
strava,
|
||||||
@@ -63,6 +65,8 @@ for _router in [
|
|||||||
me.router,
|
me.router,
|
||||||
admin.router,
|
admin.router,
|
||||||
activities.router,
|
activities.router,
|
||||||
|
merge.router,
|
||||||
|
budget.router,
|
||||||
download.router,
|
download.router,
|
||||||
uploads.router,
|
uploads.router,
|
||||||
segments.router,
|
segments.router,
|
||||||
|
|||||||
@@ -36,6 +36,25 @@
|
|||||||
pr_elapsed_s: number;
|
pr_elapsed_s: number;
|
||||||
}
|
}
|
||||||
let segmentEfforts: SegmentEffortHit[] = [];
|
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)
|
// Local overrides applied immediately after a save (no re-fetch needed)
|
||||||
let localTitle = '';
|
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"
|
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>
|
>+ segment</a>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{#if descriptionHtml}
|
{#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">
|
<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. */
|
/** Logged-in handle — resolved async via bincio:me event. */
|
||||||
let me: string = '';
|
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 } {
|
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
|
||||||
if (preset === 'all') return { dateFrom: '', dateTo: '' };
|
if (preset === 'all') return { dateFrom: '', dateTo: '' };
|
||||||
if (/^\d{4}$/.test(preset)) {
|
if (/^\d{4}$/.test(preset)) {
|
||||||
@@ -74,11 +141,14 @@
|
|||||||
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T`, dateTo: '' };
|
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.
|
// Show private activities only to their owner.
|
||||||
// On a profile page (filterHandle set): show unlisted if me === filterHandle.
|
// 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.
|
// On the global feed: show unlisted only for the logged-in user's own activities.
|
||||||
$: isOwner = filterHandle !== '' && me === filterHandle;
|
$: isOwner = filterHandle !== '' && me === filterHandle;
|
||||||
$: withPrivacy = all.filter(a => {
|
$: withPrivacy = withMerge.filter(a => {
|
||||||
if (isUnlisted(a.privacy)) {
|
if (isUnlisted(a.privacy)) {
|
||||||
return filterHandle ? isOwner : (me !== '' && (a as any).handle === me);
|
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.
|
// Resolve the logged-in handle so we can show the owner their private activities.
|
||||||
if ((window as any).__bincioMe !== undefined) {
|
if ((window as any).__bincioMe !== undefined) {
|
||||||
me = (window as any).__bincioMe;
|
me = (window as any).__bincioMe;
|
||||||
|
_loadMerges();
|
||||||
} else {
|
} 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 {
|
try {
|
||||||
@@ -287,6 +361,17 @@
|
|||||||
on:click={() => viewMode = 'map'}
|
on:click={() => viewMode = 'map'}
|
||||||
>Map</button>
|
>Map</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,8 +453,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{#each visible as a (a.id)}
|
{#each visible as a (a.id)}
|
||||||
|
{@const isSelected = selected.has(a.id)}
|
||||||
<!-- relative + isolate so the stretched activity link stays below the handle link -->
|
<!-- 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 -->
|
<!-- header -->
|
||||||
<div class="flex items-start justify-between gap-2 mb-3">
|
<div class="flex items-start justify-between gap-2 mb-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -384,19 +476,34 @@
|
|||||||
{#if isUnlisted(a.privacy)}
|
{#if isUnlisted(a.privacy)}
|
||||||
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
|
<span class="text-zinc-500 shrink-0" title="Unlisted">🔒</span>
|
||||||
{/if}
|
{/if}
|
||||||
<a
|
{#if selectMode}
|
||||||
href={a.detail_url ? `${import.meta.env.BASE_URL}activity/${a.id}/` : `${import.meta.env.BASE_URL}activity/local/?id=${a.id}`}
|
<span class="truncate">{a.title}</span>
|
||||||
class="before:absolute before:inset-0 before:content-[''] truncate"
|
{:else}
|
||||||
on:click={() => { try { sessionStorage.setItem(`bincio:activity:${a.id}`, JSON.stringify(a)); } catch {} }}
|
<a
|
||||||
>{a.title}</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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span
|
{#if selectMode}
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
<div
|
||||||
style="background:{sportColor(a.sport)}22; color:{sportColor(a.sport)}"
|
class="shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors"
|
||||||
>
|
class:border-zinc-600={!isSelected}
|
||||||
{sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)}
|
class:border-[--accent]={isSelected}
|
||||||
</span>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- track thumbnail -->
|
<!-- track thumbnail -->
|
||||||
@@ -466,3 +573,35 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/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;
|
source_file?: string | null;
|
||||||
download_disabled?: boolean;
|
download_disabled?: boolean;
|
||||||
custom: Record<string, unknown>;
|
custom: Record<string, unknown>;
|
||||||
|
/** IDs of secondary activities merged into this one (set by /api/merge). */
|
||||||
|
merged_ids?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Lap {
|
export interface Lap {
|
||||||
|
|||||||
Reference in New Issue
Block a user