423 lines
19 KiB
Plaintext
423 lines
19 KiB
Plaintext
---
|
||
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>
|