Files
bincio-activity/site/src/layouts/Base.astro
T
2026-04-06 22:25:57 +02:00

423 lines
19 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>
<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>
<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 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();
stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date.`;
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';
}
});
// 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>