Files
bincio-activity/site/src/layouts/Base.astro
T
Davide Scaini 36a91362d9 Strava sync improvements
- Fix first sync finding 0 activities: remove last_sync_at stamp at
    connect time so the first sync checks all Strava history (existence
    check skips already-extracted files without fetching streams)
  - Add POST /api/strava/reset with soft/hard modes: soft sets last_sync_at
    to the most recent activity already on disk; hard clears it entirely
  - Surface error_count in sync response and status message
  - Add Reset / Hard reset buttons below Sync now in the upload modal
  - Reload on bfcache restore so client:only components re-mount after
    back navigation
2026-04-08 14:23:52 +02:00

475 lines
22 KiB
Plaintext
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.
---
interface Props {
title?: string;
description?: string;
}
const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props;
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
const baseUrl = import.meta.env.BASE_URL ?? '/';
---
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
<!-- Set theme before first paint to avoid flash -->
<script is:inline>
const t = localStorage.getItem('bincio-theme') || 'dark';
document.documentElement.setAttribute('data-theme', t);
</script>
<!-- Reload on bfcache restore so client:only components re-mount -->
<script is:inline>
window.addEventListener('pageshow', function(e) {
if (e.persisted) window.location.reload();
});
</script>
<style is:global>
/* ── Theme tokens ─────────────────────────────────────────────────────── */
:root, [data-theme="dark"] {
--bg-base: #09090b; /* zinc-950 */
--bg-card: #18181b; /* zinc-900 */
--bg-elevated: #27272a; /* zinc-800 */
--bg-subtle: #3f3f46; /* zinc-700 */
--text-primary: #ffffff;
--text-2: #e4e4e7; /* zinc-200 */
--text-3: #d4d4d8; /* zinc-300 */
--text-4: #a1a1aa; /* zinc-400 */
--text-5: #71717a; /* zinc-500 */
--border: #27272a; /* zinc-800 */
--border-sub: #3f3f46; /* zinc-700 */
--accent: #00c8ff;
--accent-dim: rgba(0,200,255,0.15);
}
[data-theme="light"] {
--bg-base: #fafafa; /* zinc-50 */
--bg-card: #f4f4f5; /* zinc-100 */
--bg-elevated: #e4e4e7; /* zinc-200 */
--bg-subtle: #d4d4d8; /* zinc-300 */
--text-primary: #18181b; /* zinc-900 */
--text-2: #27272a; /* zinc-800 */
--text-3: #3f3f46; /* zinc-700 */
--text-4: #52525b; /* zinc-600 */
--text-5: #71717a; /* zinc-500 */
--border: #e4e4e7; /* zinc-200 */
--border-sub: #d4d4d8; /* zinc-300 */
--accent: #0284c7; /* sky-600 */
--accent-dim: rgba(2,132,199,0.12);
}
/* ── Tailwind zinc overrides for light mode ────────────────────────────
Overrides hardcoded Tailwind classes from all components in one place.
!important needed to beat Tailwind's generated specificity. */
[data-theme="light"] .bg-zinc-950 { background-color: var(--bg-base) !important; }
[data-theme="light"] .bg-zinc-900 { background-color: var(--bg-card) !important; }
[data-theme="light"] .bg-zinc-800 { background-color: var(--bg-elevated) !important; }
[data-theme="light"] .bg-zinc-700 { background-color: var(--bg-subtle) !important; }
[data-theme="light"] .text-white { color: var(--text-primary) !important; }
[data-theme="light"] .text-zinc-100{ color: var(--text-primary) !important; }
[data-theme="light"] .text-zinc-200{ color: var(--text-2) !important; }
[data-theme="light"] .text-zinc-300{ color: var(--text-3) !important; }
[data-theme="light"] .text-zinc-400{ color: var(--text-4) !important; }
[data-theme="light"] .text-zinc-500{ color: var(--text-5) !important; }
[data-theme="light"] .border-zinc-800 { border-color: var(--border) !important; }
[data-theme="light"] .border-zinc-700 { border-color: var(--border-sub) !important; }
/* Opacity variants (nav backdrop, tooltips, overlays) */
[data-theme="light"] .bg-zinc-950\/90 { background-color: rgba(250,250,250,0.92) !important; }
[data-theme="light"] .bg-zinc-900\/95 { background-color: rgba(244,244,245,0.95) !important; }
/* Soften shadows in light mode */
[data-theme="light"] .shadow-2xl {
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.12) !important;
}
/* ── Base reset ─────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; }
.maplibregl-canvas { outline: none; }
</style>
</head>
<body
class="font-sans antialiased min-h-screen"
style="background-color: var(--bg-base); color: var(--text-primary)"
>
<nav
class="border-b border-zinc-800 sticky top-0 z-50 bg-zinc-950/90 backdrop-blur"
style="border-color: var(--border)"
>
<div class="max-w-7xl mx-auto px-4 h-12 flex items-center gap-6">
<a href={baseUrl} class="font-bold text-white tracking-tight hover:text-[--accent] transition-colors">
Bincio<span class="text-[--accent]">Activity</span>
</a>
<a href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
<a href={`${baseUrl}stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
<a href={`${baseUrl}athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
<a href={`${baseUrl}record/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
<div class="ml-auto flex items-center gap-1">
{editUrl && (
<button
id="upload-btn"
class="text-zinc-400 hover:text-white transition-colors w-8 h-8 flex items-center justify-center rounded-md hover:bg-zinc-800 text-base"
aria-label="Upload activity"
title="Upload activity"
>↑</button>
)}
<button
id="theme-toggle"
class="text-zinc-400 hover:text-white transition-colors w-8 h-8 flex items-center justify-center rounded-md hover:bg-zinc-800 text-base"
aria-label="Toggle theme"
>☀</button>
</div>
</div>
</nav>
{editUrl && (
<!-- Add activity modal -->
<div
id="upload-modal"
style="display:none"
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-label="Add activity"
>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<div class="flex items-center justify-between mb-4">
<h2 id="upload-title" class="font-semibold text-white text-sm">Add activity</h2>
<button id="upload-close" class="text-zinc-500 hover:text-white transition-colors text-xl leading-none" aria-label="Close">×</button>
</div>
<!-- View: choose source -->
<div id="upload-view-choose">
<div class="flex flex-col gap-3">
<button
id="upload-choose-file"
class="flex items-center gap-3 p-4 rounded-lg border border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800 transition-colors text-left"
>
<span class="text-2xl">📁</span>
<div>
<p class="text-sm font-medium text-white">Upload file</p>
<p class="text-xs text-zinc-500">FIT, GPX, or TCX</p>
</div>
</button>
<button
id="upload-choose-strava"
class="flex items-center gap-3 p-4 rounded-lg border border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800 transition-colors text-left"
>
<span class="text-2xl">🟠</span>
<div>
<p class="text-sm font-medium text-white">Sync from Strava</p>
<p id="strava-choose-sub" class="text-xs text-zinc-500">Checking…</p>
</div>
</button>
</div>
</div>
<!-- View: file upload -->
<div id="upload-view-file" style="display:none">
<button id="upload-back-file" class="text-xs text-zinc-500 hover:text-white mb-3 transition-colors">← Back</button>
<div
id="upload-drop"
class="border-2 border-dashed border-zinc-700 rounded-lg p-8 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
>
<div id="upload-label">Drop a FIT, GPX, or TCX file<br/>or click to browse</div>
<input id="upload-input" type="file" accept=".fit,.gpx,.tcx,.fit.gz,.gpx.gz,.tcx.gz" class="hidden" />
</div>
<p id="upload-status" class="mt-3 text-xs text-center" style="color: var(--text-5); min-height: 1.25rem"></p>
</div>
<!-- View: Strava sync -->
<div id="upload-view-strava" style="display:none">
<button id="upload-back-strava" class="text-xs text-zinc-500 hover:text-white mb-3 transition-colors">← Back</button>
<div id="strava-connect-area" style="display:none">
<p class="text-sm text-zinc-400 mb-4">Connect your Strava account to sync activities automatically.</p>
<button
id="strava-connect-btn"
class="w-full py-2 px-4 rounded-lg font-medium text-sm transition-colors"
style="background:#fc4c02; color:white;"
>Connect Strava</button>
</div>
<div id="strava-sync-area" style="display:none">
<p class="text-xs text-zinc-500 mb-1">Last sync: <span id="strava-last-sync">never</span></p>
<button
id="strava-sync-btn"
class="w-full py-2 px-4 rounded-lg font-medium text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors mt-2"
>Sync now</button>
<div class="flex gap-2 mt-1">
<button
id="strava-reset-soft-btn"
class="flex-1 py-1.5 px-3 rounded-lg text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
title="Set sync point to your most recent activity — next sync only fetches newer ones"
>Reset</button>
<button
id="strava-reset-hard-btn"
class="flex-1 py-1.5 px-3 rounded-lg text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
title="Clear sync point — next sync re-checks all Strava activities"
>Hard reset</button>
</div>
</div>
<p id="strava-status" class="mt-3 text-xs text-center" style="min-height: 1.25rem"></p>
</div>
</div>
</div>
)}
<main class="max-w-7xl mx-auto px-4 py-6">
<slot />
</main>
<script>
// Register service worker for local activity storage (offline support)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
<script>
const btn = document.getElementById('theme-toggle') as HTMLButtonElement;
function applyTheme(theme: string) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('bincio-theme', theme);
btn.textContent = theme === 'dark' ? '☀' : '☾';
btn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
}
// Sync icon with current theme (set by inline script above)
const current = document.documentElement.getAttribute('data-theme') || 'dark';
applyTheme(current);
btn.addEventListener('click', () => {
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
applyTheme(next);
});
</script>
{editUrl && (
<script define:vars={{ editUrl, baseUrl }}>
const modal = document.getElementById('upload-modal');
const openBtn = document.getElementById('upload-btn');
const closeBtn = document.getElementById('upload-close');
const viewChoose = document.getElementById('upload-view-choose');
const viewFile = document.getElementById('upload-view-file');
const viewStrava = document.getElementById('upload-view-strava');
const chooseFile = document.getElementById('upload-choose-file');
const chooseStrava = document.getElementById('upload-choose-strava');
const backFile = document.getElementById('upload-back-file');
const backStrava = document.getElementById('upload-back-strava');
const drop = document.getElementById('upload-drop');
const input = document.getElementById('upload-input');
const label = document.getElementById('upload-label');
const fileStatus = document.getElementById('upload-status');
const stravaStatus = document.getElementById('strava-status');
const stravaConnect = document.getElementById('strava-connect-area');
const stravaSync = document.getElementById('strava-sync-area');
const stravaConnBtn = document.getElementById('strava-connect-btn');
const stravaSyncBtn = document.getElementById('strava-sync-btn');
const stravaResetSoftBtn = document.getElementById('strava-reset-soft-btn');
const stravaResetHardBtn = document.getElementById('strava-reset-hard-btn');
const stravaLastSync = document.getElementById('strava-last-sync');
const stravaChooseSub = document.getElementById('strava-choose-sub');
// ── view helpers ──────────────────────────────────────────────────────
function showView(name) {
viewChoose.style.display = name === 'choose' ? '' : 'none';
viewFile.style.display = name === 'file' ? '' : 'none';
viewStrava.style.display = name === 'strava' ? '' : 'none';
}
function openModal() {
showView('choose');
fileStatus.textContent = '';
label.innerHTML = 'Drop a FIT, GPX, or TCX file<br>or click to browse';
modal.style.display = 'flex';
}
function closeModal() {
modal.style.display = 'none';
}
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); });
chooseFile.addEventListener('click', () => showView('file'));
backFile.addEventListener('click', () => showView('choose'));
backStrava.addEventListener('click', () => showView('choose'));
// ── file upload ───────────────────────────────────────────────────────
drop.addEventListener('click', () => input.click());
drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; drop.style.color = 'var(--text-primary)'; });
drop.addEventListener('dragleave', () => { drop.style.borderColor = ''; drop.style.color = ''; });
drop.addEventListener('drop', e => {
e.preventDefault();
drop.style.borderColor = '';
drop.style.color = '';
if (e.dataTransfer?.files[0]) doUpload(e.dataTransfer.files[0]);
});
input.addEventListener('change', () => { if (input.files?.[0]) doUpload(input.files[0]); });
async function doUpload(file) {
label.textContent = file.name;
fileStatus.textContent = 'Uploading…';
fileStatus.style.color = 'var(--text-4)';
drop.style.pointerEvents = 'none';
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', body: fd });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
fileStatus.textContent = 'Done! Opening activity…';
fileStatus.style.color = '#4ade80';
setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600);
} catch (e) {
fileStatus.textContent = 'Error: ' + e.message;
fileStatus.style.color = '#f87171';
drop.style.pointerEvents = '';
input.value = '';
}
}
// ── Strava ────────────────────────────────────────────────────────────
let stravaConfigured = false;
async function loadStravaStatus() {
try {
const r = await fetch(`${editUrl}/api/strava/status`);
if (!r.ok) return;
const d = await r.json();
stravaConfigured = d.configured;
if (!d.configured) {
stravaChooseSub.textContent = 'Not configured';
chooseStrava.disabled = true;
chooseStrava.classList.add('opacity-40', 'cursor-not-allowed');
return;
}
stravaChooseSub.textContent = d.connected
? (d.last_sync ? 'Connected · tap to sync' : 'Connected · never synced')
: 'Tap to connect';
stravaConnect.style.display = d.connected ? 'none' : '';
stravaSync.style.display = d.connected ? '' : 'none';
if (d.last_sync) {
stravaLastSync.textContent = new Date(d.last_sync * 1000).toLocaleString();
}
} catch (_) {}
}
loadStravaStatus();
chooseStrava.addEventListener('click', () => {
if (!stravaConfigured) return;
showView('strava');
});
stravaConnBtn.addEventListener('click', async () => {
stravaStatus.textContent = 'Opening Strava…';
stravaStatus.style.color = 'var(--text-4)';
try {
const r = await fetch(`${editUrl}/api/strava/auth-url`);
if (!r.ok) throw new Error(await r.text());
const { url } = await r.json();
const popup = window.open(url, 'strava-auth', 'width=600,height=700,left=200,top=100');
stravaStatus.textContent = 'Waiting for Strava authorisation…';
// Listen for the callback redirect closing the popup
const poll = setInterval(() => {
try {
if (popup && popup.location.href.includes('strava=connected')) {
clearInterval(poll);
popup.close();
stravaStatus.textContent = 'Connected!';
stravaStatus.style.color = '#4ade80';
stravaConnect.style.display = 'none';
stravaSync.style.display = '';
stravaLastSync.textContent = 'never';
} else if (popup && popup.location.href.includes('strava=error')) {
clearInterval(poll);
popup.close();
stravaStatus.textContent = 'Authorisation failed.';
stravaStatus.style.color = '#f87171';
}
} catch (_) {}
if (popup && popup.closed) clearInterval(poll);
}, 500);
} catch (e) {
stravaStatus.textContent = 'Error: ' + e.message;
stravaStatus.style.color = '#f87171';
}
});
stravaSyncBtn.addEventListener('click', async () => {
stravaSyncBtn.disabled = true;
stravaSyncBtn.textContent = 'Syncing…';
stravaStatus.textContent = '';
try {
const r = await fetch(`${editUrl}/api/strava/sync`, { method: 'POST' });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
stravaLastSync.textContent = new Date().toLocaleString();
const errNote = d.error_count ? `, ${d.error_count} errors` : '';
stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`;
stravaStatus.style.color = '#4ade80';
if (d.imported > 0) setTimeout(() => window.location.reload(), 1500);
} catch (e) {
stravaStatus.textContent = 'Error: ' + e.message;
stravaStatus.style.color = '#f87171';
} finally {
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
}
});
async function stravaReset(mode) {
const btn = mode === 'soft' ? stravaResetSoftBtn : stravaResetHardBtn;
btn.disabled = true;
stravaStatus.textContent = '';
try {
const r = await fetch(`${editUrl}/api/strava/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
if (mode === 'hard') {
stravaStatus.textContent = 'Hard reset done — next sync will re-check all activities.';
} else {
const date = d.last_sync_at ? new Date(d.last_sync_at * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : 'none';
stravaStatus.textContent = `Reset to ${date} — next sync fetches only newer activities.`;
}
stravaStatus.style.color = '#a1a1aa';
} catch (e) {
stravaStatus.textContent = 'Error: ' + e.message;
stravaStatus.style.color = '#f87171';
} finally {
btn.disabled = false;
}
}
stravaResetSoftBtn.addEventListener('click', () => stravaReset('soft'));
stravaResetHardBtn.addEventListener('click', () => stravaReset('hard'));
// Handle ?strava= param set by the callback redirect (popup scenario)
const sp = new URLSearchParams(window.location.search);
if (sp.has('strava')) {
history.replaceState(null, '', window.location.pathname);
}
</script>
)}
</body>
</html>