Files
bincio-activity/site/src/components/EditDrawer.svelte
T
Davide Scaini fb202b4edf Record / Convert tabs — now gated behind PUBLIC_MOBILE_APP=true. Hidden by default in VPS/dev mode; only show when explicitly opted into the mobile app build.
Edit/Upload UI — split into two concepts:
  - PUBLIC_EDIT_URL — the server base URL (empty = use proxy at /api/)
  - PUBLIC_EDIT_ENABLED=true — whether to show the edit/upload buttons at all

  bincio dev now sets PUBLIC_EDIT_ENABLED=true when instance.db exists (multi-user mode), so the upload button, edit button, and edit drawer all appear. The fetch calls already produce  correct relative URLs (${''}/api/upload = /api/upload) which the Vite proxy forwards to bincio serve.
2026-04-09 13:16:00 +02:00

300 lines
10 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Sport } from '../lib/types';
export let activityId: string;
export let editUrl: string;
const dispatch = createEventDispatcher<{ saved: { title: string; description: string }; close: void }>();
const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other'];
const STAT_PANELS = [
{ key: 'elevation', label: 'Elevation' },
{ key: 'speed', label: 'Speed' },
{ key: 'heart_rate', label: 'Heart rate' },
{ key: 'cadence', label: 'Cadence' },
{ key: 'power', label: 'Power' },
];
let loading = true;
let loadError = '';
let saving = false;
let saveStatus = '';
let saveOk = false;
// Form state
let title = '';
let sport: Sport = 'cycling';
let gear = '';
let description = '';
let highlight = false;
let isPrivate = false;
let hideStats: string[] = [];
let images: string[] = [];
// Image upload
let uploading = false;
let fileInput: HTMLInputElement;
// editUrl is empty in multi-user VPS mode — the Vite proxy forwards /api/* to bincio serve.
const api = `${editUrl}/api/activity/${activityId}`;
async function load() {
loading = true;
loadError = '';
try {
const res = await fetch(api);
if (!res.ok) throw new Error(`Edit server returned ${res.status} — is bincio edit running?`);
const d = await res.json();
title = d.title ?? '';
sport = d.sport ?? 'cycling';
gear = d.gear ?? '';
description = d.description ?? '';
highlight = d.highlight ?? false;
isPrivate = d.private ?? false;
hideStats = d.hide_stats ?? [];
images = d.images ?? [];
} catch (e: any) {
loadError = e.message;
} finally {
loading = false;
}
}
async function save() {
saving = true;
saveStatus = '';
saveOk = false;
try {
const res = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, sport, gear, description, highlight, private: isPrivate, hide_stats: hideStats }),
});
if (!res.ok) throw new Error(await res.text());
saveStatus = 'Saved';
saveOk = true;
dispatch('saved', { title, description });
} catch (e: any) {
saveStatus = e.message;
saveOk = false;
} finally {
saving = false;
}
}
async function uploadImages(files: FileList) {
uploading = true;
try {
for (const file of Array.from(files)) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${api}/images`, { method: 'POST', body: fd });
if (res.ok) {
const d = await res.json();
if (!images.includes(d.filename)) images = [...images, d.filename];
// Insert markdown reference at cursor or end
const ref = `\n![${d.filename.replace(/\.[^.]+$/, '')}](${d.filename})`;
description = description.trimEnd() + ref;
}
}
} catch (e: any) {
saveStatus = `Upload failed: ${e.message}`;
saveOk = false;
} finally {
uploading = false;
}
}
async function deleteImage(filename: string) {
await fetch(`${api}/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
images = images.filter(f => f !== filename);
// Remove the markdown reference — escape filename before using in regex
const escaped = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
description = description.replace(new RegExp(`!\\[[^\\]]*\\]\\(${escaped}\\)`, 'g'), '').trim();
}
function toggleStat(key: string) {
hideStats = hideStats.includes(key)
? hideStats.filter(s => s !== key)
: [...hideStats, key];
}
load();
</script>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/60 z-40 backdrop-blur-sm"
on:click={() => dispatch('close')}
role="presentation"
></div>
<!-- Drawer -->
<aside class="fixed top-0 right-0 h-full w-full max-w-md bg-zinc-950 border-l border-zinc-800 z-50 flex flex-col shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-zinc-800 shrink-0">
<h2 class="font-semibold text-white text-sm">Edit activity</h2>
<button
class="text-zinc-500 hover:text-white transition-colors text-xl leading-none"
on:click={() => dispatch('close')}
aria-label="Close"
>×</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-5 py-4">
{#if loading}
<div class="space-y-3 animate-pulse">
{#each Array(4) as _}
<div class="h-9 rounded bg-zinc-800"></div>
{/each}
</div>
{:else if loadError}
<p class="text-red-400 text-sm">{loadError}</p>
{:else}
<!-- Title -->
<div class="mb-4">
<label class="block text-xs text-zinc-500 mb-1" for="ed-title">Title</label>
<input
id="ed-title"
type="text"
bind:value={title}
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
/>
</div>
<!-- Sport + Gear -->
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="block text-xs text-zinc-500 mb-1" for="ed-sport">Sport</label>
<select
id="ed-sport"
bind:value={sport}
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
>
{#each SPORTS as s}
<option value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
{/each}
</select>
</div>
<div>
<label class="block text-xs text-zinc-500 mb-1" for="ed-gear">Gear</label>
<input
id="ed-gear"
type="text"
bind:value={gear}
placeholder="e.g. Trek Domane"
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors"
/>
</div>
</div>
<!-- Description -->
<div class="mb-4">
<label class="block text-xs text-zinc-500 mb-1" for="ed-desc">Description <span class="text-zinc-600">(markdown)</span></label>
<textarea
id="ed-desc"
bind:value={description}
rows={6}
placeholder="Write about this activity…"
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors resize-y"
></textarea>
</div>
<!-- Images -->
<div class="mb-4">
<p class="text-xs text-zinc-500 mb-2">Images</p>
<button
type="button"
class="w-full border border-dashed border-zinc-700 rounded-lg px-4 py-3 text-center text-xs text-zinc-500 cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
on:click={() => fileInput.click()}
on:dragover|preventDefault
on:drop|preventDefault={e => e.dataTransfer?.files && uploadImages(e.dataTransfer.files)}
>
{uploading ? 'Uploading…' : 'Drop images or click to upload'}
</button>
<input bind:this={fileInput} type="file" accept="image/*" multiple class="hidden"
on:change={e => e.currentTarget.files && uploadImages(e.currentTarget.files)} />
{#if images.length}
<div class="flex flex-wrap gap-2 mt-2">
{#each images as img}
<span class="flex items-center gap-1 text-xs bg-zinc-800 border border-zinc-700 rounded-full px-2 py-0.5">
{img}
<button class="text-zinc-500 hover:text-red-400 transition-colors" on:click={() => deleteImage(img)}>×</button>
</span>
{/each}
</div>
{/if}
</div>
<!-- Hide stats -->
<div class="mb-4">
<p class="text-xs text-zinc-500 mb-2">Hide stat panels</p>
<div class="flex flex-wrap gap-2">
{#each STAT_PANELS as panel}
<button
type="button"
class="text-xs px-3 py-1 rounded-full border transition-colors"
class:border-zinc-700={!hideStats.includes(panel.key)}
class:text-zinc-400={!hideStats.includes(panel.key)}
class:border-blue-500={hideStats.includes(panel.key)}
class:text-white={hideStats.includes(panel.key)}
style={hideStats.includes(panel.key) ? 'background:rgba(59,130,246,.15)' : ''}
on:click={() => toggleStat(panel.key)}
>
{panel.label}
</button>
{/each}
</div>
</div>
<!-- Flags -->
<div class="flex gap-3 mb-2">
<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={!highlight}
class:text-zinc-400={!highlight}
class:border-yellow-500={highlight}
class:text-yellow-300={highlight}
style={highlight ? 'background:rgba(234,179,8,.1)' : ''}
on:click={() => highlight = !highlight}
>
★ Highlight
</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={!isPrivate}
class:text-zinc-400={!isPrivate}
class:border-red-500={isPrivate}
class:text-red-300={isPrivate}
style={isPrivate ? 'background:rgba(239,68,68,.1)' : ''}
on:click={() => isPrivate = !isPrivate}
>
⊘ Private
</button>
</div>
{/if}
</div>
<!-- Footer -->
{#if !loading && !loadError}
<div class="px-5 py-4 border-t border-zinc-800 flex items-center gap-3 shrink-0">
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm font-medium rounded-lg transition-colors"
disabled={saving}
on:click={save}
>
{saving ? 'Saving…' : 'Save'}
</button>
{#if saveStatus}
<span class="text-xs" class:text-green-400={saveOk} class:text-red-400={!saveOk}>
{saveStatus}
</span>
{/if}
</div>
{/if}
</aside>