Files
bincio-activity/site/src/layouts/Base.astro
T
2026-04-10 22:01:44 +02:00

755 lines
35 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.
---
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
interface Props {
title?: string;
description?: string;
/** Set true on pages that must remain accessible without auth (login, register). */
public?: boolean;
}
const { title = 'BincioActivity', description = 'Your personal activity stats', public: isPublicPage = false } = Astro.props;
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
// Edit UI is enabled when PUBLIC_EDIT_URL is set (single-user bincio-edit mode)
// OR when PUBLIC_EDIT_ENABLED=true (multi-user VPS mode — API proxied at /api/).
const editEnabled = editUrl !== '' || import.meta.env.PUBLIC_EDIT_ENABLED === 'true';
const mobileApp = import.meta.env.PUBLIC_MOBILE_APP === 'true';
const baseUrl = import.meta.env.BASE_URL ?? '/';
// Read root index.json at build time to detect instance configuration.
let instancePrivate = false;
let singleHandle: string | null = null; // set when there is exactly one shard
try {
const candidates = [
process.env.BINCIO_DATA_DIR,
resolve(process.cwd(), 'public', 'data'),
resolve(process.cwd(), '..', 'bincio_data'),
].filter(Boolean) as string[];
const dataDir = candidates.find(d => { try { readFileSync(join(d, 'index.json')); return true; } catch { return false; } });
if (dataDir) {
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
instancePrivate = root?.instance?.private === true;
const shards: Array<{ handle?: string }> = root?.shards ?? [];
const handles = shards.map(s => s.handle).filter(Boolean);
if (handles.length === 1 && !instancePrivate) singleHandle = handles[0] as string;
}
} catch { /* non-fatal */ }
---
<!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>
<!-- Auth wall: redirect to /login/ on private instances when not authenticated.
body[data-auth-pending] is hidden by CSS (below) before the check resolves,
eliminating the flash of protected content. -->
{instancePrivate && !isPublicPage && (
<style is:inline>[data-auth-pending]{visibility:hidden}</style>
<script is:inline>
fetch('/api/me', { credentials: 'include' })
.then(r => {
if (r.status === 401 || r.status === 404) {
window.location.replace('/login/');
} else {
document.body.removeAttribute('data-auth-pending');
}
})
.catch(() => { document.body.removeAttribute('data-auth-pending'); });
</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)"
data-auth-pending={instancePrivate && !isPublicPage ? '' : undefined}
>
<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>
{!isPublicPage && (
<>
<!-- Feed tab: only shown for multi-user (more than one shard) -->
{!singleHandle && (
<a href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
)}
<!-- Single-user: static handle link. Multi-user: populated by user-widget script. -->
{singleHandle
? <a href={`${baseUrl}u/${singleHandle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">@{singleHandle}</a>
: <a id="nav-me" href="#" style="display:none" class="text-sm text-[--accent] hover:text-white transition-colors"></a>
}
<!-- Per-user nav links — updated by user-widget script in multi-user mode -->
<a id="nav-stats" href={singleHandle ? `${baseUrl}u/${singleHandle}/stats/` : `${baseUrl}stats/`} data-user-path="stats/" class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
<a id="nav-athlete" href={singleHandle ? `${baseUrl}u/${singleHandle}/athlete/` : `${baseUrl}athlete/`} data-user-path="athlete/" class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
{mobileApp && (
<a id="nav-record" href={singleHandle ? `${baseUrl}u/${singleHandle}/record/` : `${baseUrl}record/`} data-user-path="record/" class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
)}
{mobileApp && (
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
)}
<a href={`${baseUrl}about/`} class="text-sm text-zinc-400 hover:text-white transition-colors">About</a>
<a id="nav-feedback" href={`${baseUrl}feedback/`} style="display:none" class="text-sm text-zinc-400 hover:text-white transition-colors">Feedback</a>
</>
)}
<div class="ml-auto flex items-center gap-1">
{!isPublicPage && (
<>
<!-- Logout button — hidden until logged in -->
<button
id="nav-logout"
style="display:none"
class="text-xs text-zinc-500 hover:text-white transition-colors px-2 h-8"
aria-label="Log out"
>Log out</button>
{editEnabled && (
<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>
{editEnabled && (
<!-- 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>
<button
id="upload-choose-zip"
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">Strava export ZIP</p>
<p class="text-xs text-zinc-500">Import your full Strava archive</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 FIT, GPX, or TCX files<br/>or click to browse</div>
<input id="upload-input" type="file" accept=".fit,.gpx,.tcx,.fit.gz,.gpx.gz,.tcx.gz" class="hidden" multiple />
</div>
<label class="flex items-start gap-2 mt-3 cursor-pointer group">
<input
id="upload-keep-original"
type="checkbox"
class="mt-0.5 accent-blue-500 shrink-0"
/>
<span class="text-xs text-zinc-500 group-hover:text-zinc-300 transition-colors leading-snug">
Keep original file on server
<span class="text-zinc-600 block mt-0.5">Lets you reprocess if the format changes. See the <a href={`${baseUrl}about/`} class="underline hover:text-zinc-400">About page</a> for details.</span>
</span>
</label>
<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>
<!-- View: Strava ZIP upload -->
<div id="upload-view-zip" style="display:none">
<button id="upload-back-zip" class="text-xs text-zinc-500 hover:text-white mb-3 transition-colors">← Back</button>
<div class="rounded-lg border border-amber-800/50 bg-amber-950/30 p-3 mb-4 text-xs text-amber-300 leading-relaxed">
⚠ The ZIP will be processed and <strong>immediately deleted</strong> from the server — originals are not kept. Make sure you keep your own copy.
</div>
<div
id="zip-drop"
class="border-2 border-dashed border-zinc-700 rounded-lg p-6 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
>
<div id="zip-label">Drop your Strava export .zip<br/>or click to browse</div>
<input id="zip-input" type="file" accept=".zip" class="hidden" />
</div>
<p id="zip-status" class="mt-3 text-xs text-center leading-relaxed" 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>
<!-- Single-user: expose the handle synchronously so profile pages can detect "own profile" -->
{singleHandle && (
<script define:vars={{ singleHandle }}>
window.__bincioMe = singleHandle;
window.dispatchEvent(new CustomEvent('bincio:me', { detail: singleHandle }));
</script>
)}
<!-- User widget: only needed for multi-user (single-user nav links are static) -->
{!singleHandle && (
<script define:vars={{ baseUrl }}>
(async () => {
try {
const r = await fetch('/api/me', { credentials: 'include' });
if (!r.ok) return;
const user = await r.json();
// Expose handle for profile pages
window.__bincioMe = user.handle;
window.dispatchEvent(new CustomEvent('bincio:me', { detail: user.handle }));
// Show @handle link → /u/{handle}/
const meEl = document.getElementById('nav-me');
if (meEl) {
meEl.textContent = '@' + user.handle;
meEl.href = baseUrl + 'u/' + user.handle + '/';
meEl.style.display = '';
}
// Update per-user nav links to point to /u/{handle}/{page}/
document.querySelectorAll('[data-user-path]').forEach(el => {
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
});
// Show logout button and feedback link
const logoutEl = document.getElementById('nav-logout');
if (logoutEl) logoutEl.style.display = '';
const feedbackEl = document.getElementById('nav-feedback');
if (feedbackEl) feedbackEl.style.display = '';
// Pre-populate the "keep original" checkbox from the instance default
const chk = document.getElementById('upload-keep-original');
if (chk && user.store_originals_default) chk.checked = true;
} catch (_) {}
})();
document.getElementById('nav-logout')?.addEventListener('click', async () => {
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {}
window.location.href = baseUrl + 'login/';
});
</script>
)}
{editEnabled && (
<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 viewZip = document.getElementById('upload-view-zip');
const chooseFile = document.getElementById('upload-choose-file');
const chooseStrava = document.getElementById('upload-choose-strava');
const chooseZip = document.getElementById('upload-choose-zip');
const backFile = document.getElementById('upload-back-file');
const backStrava = document.getElementById('upload-back-strava');
const backZip = document.getElementById('upload-back-zip');
const zipDrop = document.getElementById('zip-drop');
const zipInput = document.getElementById('zip-input');
const zipLabel = document.getElementById('zip-label');
const zipStatus = document.getElementById('zip-status');
const drop = document.getElementById('upload-drop');
const input = document.getElementById('upload-input');
const label = document.getElementById('upload-label');
const keepOriginalChk = document.getElementById('upload-keep-original');
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';
viewZip.style.display = name === 'zip' ? '' : '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'));
chooseZip.addEventListener('click', () => showView('zip'));
backFile.addEventListener('click', () => showView('choose'));
backStrava.addEventListener('click', () => showView('choose'));
backZip.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.length) doUpload(e.dataTransfer.files);
});
input.addEventListener('change', () => { if (input.files?.length) doUpload(input.files); });
async function doUpload(files) {
const n = files.length;
label.textContent = n === 1 ? files[0].name : `${n} files selected`;
fileStatus.textContent = `Uploading ${n} file${n > 1 ? 's' : ''}…`;
fileStatus.style.color = 'var(--text-4)';
drop.style.pointerEvents = 'none';
const fd = new FormData();
for (const f of files) fd.append('files', f);
fd.append('store_original', keepOriginalChk?.checked ? 'true' : 'false');
try {
const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', credentials: 'include', body: fd });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
const dupes = d.results.filter(r => r.error === 'duplicate').length;
const errors = d.results.filter(r => !r.ok && r.error !== 'duplicate').length;
let msg = `${d.added} added`;
if (dupes) msg += `, ${dupes} duplicate${dupes > 1 ? 's' : ''}`;
if (errors) msg += `, ${errors} failed`;
fileStatus.textContent = msg;
fileStatus.style.color = d.added > 0 ? '#4ade80' : '#a1a1aa';
if (d.added > 0) setTimeout(() => { window.location.reload(); }, 1200);
else drop.style.pointerEvents = '';
} 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', () => {
stravaSyncBtn.disabled = true;
stravaSyncBtn.textContent = 'Syncing…';
stravaStatus.textContent = '';
stravaStatus.style.color = '';
const es = new EventSource(`${editUrl}/api/strava/sync/stream`, { withCredentials: true });
let imported = 0;
es.onmessage = (e) => {
const d = JSON.parse(e.data);
if (d.type === 'fetching') {
stravaStatus.textContent = 'Fetching activity list from Strava…';
} else if (d.type === 'progress') {
const pct = Math.round((d.n / d.total) * 100);
const icon = d.status === 'imported' ? '↓' : d.status === 'error' ? '✗' : '·';
stravaStatus.textContent = `${icon} ${d.n}/${d.total} (${pct}%) — ${d.name}`;
if (d.status === 'imported') imported++;
} else if (d.type === 'done') {
es.close();
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';
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
if (d.imported > 0) setTimeout(() => window.location.reload(), 1500);
} else if (d.type === 'error') {
es.close();
stravaStatus.textContent = 'Error: ' + d.message;
stravaStatus.style.color = '#f87171';
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
}
};
es.onerror = () => {
es.close();
if (stravaSyncBtn.disabled) {
stravaStatus.textContent = 'Connection lost. Check logs.';
stravaStatus.style.color = '#f87171';
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'));
// ── Strava ZIP upload ─────────────────────────────────────────────────
function doZipUpload(file) {
if (!file) return;
zipLabel.textContent = file.name;
zipStatus.textContent = 'Uploading…';
zipStatus.style.color = '';
const fd = new FormData();
fd.append('file', file);
// POST the file; server responds with SSE stream immediately after receiving body
const xhr = new XMLHttpRequest();
xhr.open('POST', `${editUrl}/api/upload/strava-zip`);
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'text/event-stream');
let buf = '';
let imported = 0;
xhr.onprogress = () => {
// Parse SSE lines from the incrementally received response text
const newText = xhr.responseText.slice(buf.length);
buf = xhr.responseText;
for (const line of newText.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === 'validating') {
zipStatus.textContent = 'Validating ZIP structure…';
} else if (ev.type === 'extracting_csv') {
zipStatus.textContent = 'Reading activities.csv…';
} else if (ev.type === 'progress') {
const pct = Math.round((ev.n / ev.total) * 100);
const icon = ev.status === 'imported' ? '↓' : ev.status === 'error' ? '✗' : '·';
zipStatus.textContent = `${icon} ${ev.n}/${ev.total} (${pct}%) — ${ev.name}`;
if (ev.status === 'imported') imported++;
} else if (ev.type === 'done') {
const errNote = ev.error_count ? `, ${ev.error_count} errors` : '';
zipStatus.textContent = `Done — ${ev.imported} imported, ${ev.skipped} already up to date${errNote}.`;
zipStatus.style.color = '#4ade80';
zipInput.value = '';
if (ev.imported > 0) setTimeout(() => window.location.reload(), 1500);
} else if (ev.type === 'error') {
zipStatus.textContent = 'Error: ' + ev.message;
zipStatus.style.color = '#f87171';
zipInput.value = '';
}
} catch (_) {}
}
};
xhr.onerror = () => {
zipStatus.textContent = 'Upload failed — check your connection.';
zipStatus.style.color = '#f87171';
};
xhr.send(fd);
}
zipDrop.addEventListener('click', () => zipInput.click());
zipInput.addEventListener('change', () => doZipUpload(zipInput.files?.[0]));
zipDrop.addEventListener('dragover', e => { e.preventDefault(); zipDrop.classList.add('border-zinc-400'); });
zipDrop.addEventListener('dragleave', () => zipDrop.classList.remove('border-zinc-400'));
zipDrop.addEventListener('drop', e => {
e.preventDefault();
zipDrop.classList.remove('border-zinc-400');
doZipUpload(e.dataTransfer?.files?.[0]);
});
// 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>