feat: gear registry — manage bikes/shoes per athlete, set per activity
- New /api/gear CRUD endpoints (gear.json per user) - Gear tab in AthleteView (owner-only): add, edit, retire items - EditDrawer gear field becomes a dropdown when registry has items - Strava API sync now resolves gear_id → name, adds to registry automatically - Strava ZIP import reads Gear column from activities.csv - POST /api/strava/import-gear for one-time backfill from stored originals
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
let error: string | null = null;
|
||||
let drawerOpen = false;
|
||||
|
||||
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
|
||||
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd' | 'gear';
|
||||
let activeTab: Tab = 'power';
|
||||
let mounted = false;
|
||||
let isOwner = false;
|
||||
@@ -50,6 +50,82 @@
|
||||
let effortsBySegment: Record<string, SegmentEffort[]> = {};
|
||||
let loadingEfforts: Record<string, boolean> = {};
|
||||
|
||||
// Gear tab state
|
||||
interface GearItem { id: string; name: string; type: string; retired: boolean; strava_id?: string }
|
||||
let gearItems: GearItem[] = [];
|
||||
let gearLoading = false;
|
||||
let gearFetched = false;
|
||||
let gearAddName = '';
|
||||
let gearAddType = 'bike';
|
||||
let gearAdding = false;
|
||||
let gearError: string | null = null;
|
||||
let gearEditId: string | null = null;
|
||||
let gearEditName = '';
|
||||
let gearEditType = 'bike';
|
||||
let gearEditRetired = false;
|
||||
let gearSaving = false;
|
||||
|
||||
const GEAR_ICONS: Record<string, string> = { bike: '🚲', shoes: '👟', skis: '🎿', other: '⚙️' };
|
||||
|
||||
async function gearFetch() {
|
||||
if (gearFetched) return;
|
||||
gearLoading = true; gearFetched = true;
|
||||
try {
|
||||
const r = await fetch('/api/gear', { credentials: 'include' });
|
||||
if (r.ok) gearItems = (await r.json()).items ?? [];
|
||||
} catch { /* ignore */ }
|
||||
gearLoading = false;
|
||||
}
|
||||
|
||||
async function gearAdd() {
|
||||
if (!gearAddName.trim()) return;
|
||||
gearAdding = true; gearError = null;
|
||||
try {
|
||||
const r = await fetch('/api/gear', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: gearAddName.trim(), type: gearAddType }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) { gearItems = [...gearItems, d.item]; gearAddName = ''; }
|
||||
else gearError = d.detail ?? 'Could not add gear.';
|
||||
} catch { gearError = 'Could not reach server.'; }
|
||||
gearAdding = false;
|
||||
}
|
||||
|
||||
function gearStartEdit(item: GearItem) {
|
||||
gearEditId = item.id; gearEditName = item.name; gearEditType = item.type; gearEditRetired = item.retired;
|
||||
}
|
||||
|
||||
async function gearSaveEdit() {
|
||||
if (!gearEditId || !gearEditName.trim()) return;
|
||||
gearSaving = true; gearError = null;
|
||||
try {
|
||||
const r = await fetch(`/api/gear/${gearEditId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: gearEditName.trim(), type: gearEditType, retired: gearEditRetired }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
gearItems = gearItems.map(g => g.id === gearEditId ? d.item : g);
|
||||
gearEditId = null;
|
||||
} else gearError = d.detail ?? 'Could not save.';
|
||||
} catch { gearError = 'Could not reach server.'; }
|
||||
gearSaving = false;
|
||||
}
|
||||
|
||||
async function gearDelete(id: string) {
|
||||
gearError = null;
|
||||
try {
|
||||
const r = await fetch(`/api/gear/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||
if (r.ok) gearItems = gearItems.filter(g => g.id !== id);
|
||||
else { const d = await r.json(); gearError = d.detail ?? 'Could not delete.'; }
|
||||
} catch { gearError = 'Could not reach server.'; }
|
||||
}
|
||||
|
||||
async function toggleSegment(id: string) {
|
||||
if (expandedId === id) { expandedId = null; return; }
|
||||
expandedId = id;
|
||||
@@ -73,6 +149,8 @@
|
||||
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
|
||||
}
|
||||
|
||||
$: if (activeTab === 'gear' && isOwner && !gearFetched && !gearLoading) gearFetch();
|
||||
|
||||
$: if (activeTab === 'segments' && segmentsHandle && !segmentsFetched && !segmentsLoading) {
|
||||
segmentsLoading = true;
|
||||
segmentsFetched = true;
|
||||
@@ -94,7 +172,7 @@
|
||||
isOwner = (e as CustomEvent<string>).detail === handle;
|
||||
}, { once: true });
|
||||
}
|
||||
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd'];
|
||||
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd', 'gear'];
|
||||
const rawTab = new URLSearchParams(window.location.search).get('tab');
|
||||
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
|
||||
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
|
||||
@@ -163,6 +241,7 @@
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
||||
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
|
||||
{ key: 'gear', label: 'Gear', ownerOnly: true },
|
||||
];
|
||||
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
|
||||
</script>
|
||||
@@ -357,6 +436,100 @@
|
||||
<Explore {handle} {base} embedded={true} />
|
||||
</div>
|
||||
|
||||
<!-- Gear tab -->
|
||||
{:else if activeTab === 'gear'}
|
||||
<div class="space-y-3">
|
||||
{#if gearError}
|
||||
<p class="text-red-400 text-sm">{gearError}</p>
|
||||
{/if}
|
||||
{#if gearLoading}
|
||||
<p class="text-zinc-400 text-sm">Loading…</p>
|
||||
{:else}
|
||||
{#if gearItems.length > 0}
|
||||
<div class="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
{#each gearItems as item (item.id)}
|
||||
{#if gearEditId === item.id}
|
||||
<!-- Inline edit form -->
|
||||
<div class="px-4 py-3 border-b border-zinc-800 flex flex-wrap gap-2 items-center bg-zinc-800/50">
|
||||
<input
|
||||
bind:value={gearEditName}
|
||||
class="flex-1 min-w-32 bg-zinc-700 text-white text-sm px-2 py-1 rounded border border-zinc-600 focus:border-blue-500 outline-none"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<select
|
||||
bind:value={gearEditType}
|
||||
class="bg-zinc-700 text-white text-sm px-2 py-1 rounded border border-zinc-600 focus:border-blue-500 outline-none"
|
||||
>
|
||||
<option value="bike">Bike</option>
|
||||
<option value="shoes">Shoes</option>
|
||||
<option value="skis">Skis</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-1.5 text-sm text-zinc-400 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={gearEditRetired} class="accent-blue-500" />
|
||||
Retired
|
||||
</label>
|
||||
<button
|
||||
on:click={gearSaveEdit}
|
||||
disabled={gearSaving || !gearEditName.trim()}
|
||||
class="text-xs px-3 py-1 rounded bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-40"
|
||||
>{gearSaving ? 'Saving…' : 'Save'}</button>
|
||||
<button
|
||||
on:click={() => gearEditId = null}
|
||||
class="text-xs px-3 py-1 rounded border border-zinc-600 text-zinc-400 hover:text-white transition-colors"
|
||||
>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 py-3 border-b border-zinc-800 flex items-center gap-3 last:border-0">
|
||||
<span class="text-lg leading-none">{GEAR_ICONS[item.type] ?? '⚙️'}</span>
|
||||
<span class="flex-1 text-sm text-white" class:text-zinc-500={item.retired}>{item.name}</span>
|
||||
{#if item.retired}
|
||||
<span class="text-xs text-zinc-600 border border-zinc-700 rounded px-1.5 py-0.5">Retired</span>
|
||||
{/if}
|
||||
<button
|
||||
on:click={() => gearStartEdit(item)}
|
||||
class="text-xs text-zinc-500 hover:text-white transition-colors"
|
||||
aria-label="Edit {item.name}"
|
||||
>Edit</button>
|
||||
<button
|
||||
on:click={() => gearDelete(item.id)}
|
||||
class="text-xs text-zinc-600 hover:text-red-400 transition-colors"
|
||||
aria-label="Delete {item.name}"
|
||||
>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-zinc-500 text-sm">No gear registered yet.</p>
|
||||
{/if}
|
||||
|
||||
<!-- Add gear form -->
|
||||
<div class="flex flex-wrap gap-2 items-center pt-1">
|
||||
<input
|
||||
bind:value={gearAddName}
|
||||
placeholder="Name (e.g. Rose Backroad)"
|
||||
class="flex-1 min-w-40 bg-zinc-800 text-white text-sm px-3 py-1.5 rounded-lg border border-zinc-700 focus:border-blue-500 outline-none placeholder:text-zinc-600"
|
||||
on:keydown={e => e.key === 'Enter' && gearAdd()}
|
||||
/>
|
||||
<select
|
||||
bind:value={gearAddType}
|
||||
class="bg-zinc-800 text-white text-sm px-2 py-1.5 rounded-lg border border-zinc-700 focus:border-blue-500 outline-none"
|
||||
>
|
||||
<option value="bike">Bike</option>
|
||||
<option value="shoes">Shoes</option>
|
||||
<option value="skis">Skis</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<button
|
||||
on:click={gearAdd}
|
||||
disabled={gearAdding || !gearAddName.trim()}
|
||||
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 hover:border-zinc-500 text-zinc-400 hover:text-white transition-colors disabled:opacity-40"
|
||||
>{gearAdding ? 'Adding…' : '+ Add gear'}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Profile tab -->
|
||||
{:else if activeTab === 'profile'}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -48,6 +48,13 @@
|
||||
let hideStats: string[] = [];
|
||||
let images: string[] = [];
|
||||
|
||||
// Gear registry
|
||||
interface GearItem { id: string; name: string; type: string; retired: boolean }
|
||||
let gearItems: GearItem[] = [];
|
||||
let gearSelectValue = ''; // the <select> value; '__other__' means free-text mode
|
||||
$: gearIsOther = gearSelectValue === '__other__';
|
||||
$: if (!gearIsOther) gear = gearSelectValue; // sync gear when using dropdown
|
||||
|
||||
// Image upload
|
||||
let uploading = false;
|
||||
let fileInput: HTMLInputElement;
|
||||
@@ -59,7 +66,10 @@
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await fetch(api);
|
||||
const [res, gearRes] = await Promise.all([
|
||||
fetch(api),
|
||||
fetch('/api/gear', { credentials: 'include' }).catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error(`Edit server returned ${res.status} — is bincio edit running?`);
|
||||
const d = await res.json();
|
||||
title = d.title ?? '';
|
||||
@@ -75,6 +85,18 @@
|
||||
downloadDisabled = d.download_disabled ?? false;
|
||||
hideStats = d.hide_stats ?? [];
|
||||
images = d.images ?? [];
|
||||
|
||||
if (gearRes?.ok) {
|
||||
gearItems = ((await gearRes.json()).items ?? []).filter((g: GearItem) => !g.retired);
|
||||
}
|
||||
// Set dropdown to current gear value, or 'Other' if it's a custom string not in the list
|
||||
if (!gear) {
|
||||
gearSelectValue = '';
|
||||
} else if (gearItems.some(g => g.name === gear)) {
|
||||
gearSelectValue = gear;
|
||||
} else {
|
||||
gearSelectValue = '__other__';
|
||||
}
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
@@ -233,13 +255,35 @@
|
||||
</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"
|
||||
/>
|
||||
{#if gearItems.length > 0}
|
||||
<select
|
||||
id="ed-gear"
|
||||
bind:value={gearSelectValue}
|
||||
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"
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{#each gearItems as g (g.id)}
|
||||
<option value={g.name}>{g.name}</option>
|
||||
{/each}
|
||||
<option value="__other__">Other…</option>
|
||||
</select>
|
||||
{#if gearIsOther}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={gear}
|
||||
placeholder="Type gear name"
|
||||
class="mt-1.5 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"
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<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"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if SUB_SPORTS[sport]}
|
||||
|
||||
Reference in New Issue
Block a user