Add activity file downloads with per-activity download_disabled flag
New endpoint: GET /api/activity/{id}/download/{bas|original|gpx}
- bas: streams the BAS detail JSON as an attachment
- original: streams the original FIT or GPX file from originals/
- gpx: generates a GPX from the timeseries (always available when GPS exists)
download_disabled flag stored in sidecar (edits/{id}.md), propagated to
the merged BAS detail JSON. When set, only the owner can download.
Backend: ops.py writes flag to sidecar; merge.py propagates it to detail
JSON; download.py implements the endpoint; server.py registers the router.
Frontend: EditDrawer gets a "No download" toggle button; ActivityDetail
shows a Download section (hidden when disabled and viewer is not the owner).
This commit is contained in:
@@ -403,6 +403,34 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Download -->
|
||||
{#if detail && (!detail.download_disabled || editEnabled)}
|
||||
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 px-4 py-3">
|
||||
<p class="text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2">Download</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/api/activity/{activity.id}/download/bas"
|
||||
download
|
||||
class="px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
|
||||
>⬇ BAS JSON</a>
|
||||
{#if detail.timeseries_url}
|
||||
<a
|
||||
href="/api/activity/{activity.id}/download/gpx"
|
||||
download
|
||||
class="px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
|
||||
>⬇ GPX</a>
|
||||
{/if}
|
||||
{#if detail.source === 'fit_file' || detail.source === 'gpx_file'}
|
||||
<a
|
||||
href="/api/activity/{activity.id}/download/original"
|
||||
download
|
||||
class="px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
|
||||
>⬇ Original {detail.source === 'fit_file' ? 'FIT' : 'GPX'}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Segment efforts -->
|
||||
{#if segmentEfforts.length > 0}
|
||||
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
let description = '';
|
||||
let highlight = false;
|
||||
let isPrivate = false;
|
||||
let downloadDisabled = false;
|
||||
let hideStats: string[] = [];
|
||||
let images: string[] = [];
|
||||
|
||||
@@ -70,8 +71,9 @@
|
||||
highlight = d.highlight ?? false;
|
||||
// d.private is a bool (from the API); d.privacy is the raw field on older
|
||||
// endpoints. Accept either so the drawer works with both serve and edit servers.
|
||||
isPrivate = d.private ?? (d.privacy === 'unlisted' || d.privacy === 'private') ?? false;
|
||||
hideStats = d.hide_stats ?? [];
|
||||
isPrivate = d.private ?? (d.privacy === 'unlisted' || d.privacy === 'private') ?? false;
|
||||
downloadDisabled = d.download_disabled ?? false;
|
||||
hideStats = d.hide_stats ?? [];
|
||||
images = d.images ?? [];
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
@@ -88,7 +90,7 @@
|
||||
const res = await fetch(api, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, sport, sub_sport: subSport || null, gear, description, highlight, private: isPrivate, hide_stats: hideStats }),
|
||||
body: JSON.stringify({ title, sport, sub_sport: subSport || null, gear, description, highlight, private: isPrivate, download_disabled: downloadDisabled, hide_stats: hideStats }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
saveStatus = 'Saved';
|
||||
@@ -373,6 +375,18 @@
|
||||
>
|
||||
⊘ Unlisted
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-colors"
|
||||
class:border-zinc-700={!downloadDisabled}
|
||||
class:text-zinc-400={!downloadDisabled}
|
||||
class:border-orange-500={downloadDisabled}
|
||||
class:text-orange-300={downloadDisabled}
|
||||
style={downloadDisabled ? 'background:rgba(249,115,22,.1)' : ''}
|
||||
on:click={() => downloadDisabled = !downloadDisabled}
|
||||
>
|
||||
⬇ No download
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -124,6 +124,8 @@ export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'tr
|
||||
mmp: MmpCurve | null;
|
||||
strava_id: string | null;
|
||||
duplicate_of: string | null;
|
||||
source_file?: string | null;
|
||||
download_disabled?: boolean;
|
||||
custom: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user