unify single user and multi user behaviour
This commit is contained in:
@@ -7,6 +7,10 @@
|
||||
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
||||
|
||||
export let base: string = '/';
|
||||
/** Explicit index URL for multi-user per-user pages (user's shard). */
|
||||
export let indexUrl: string = '';
|
||||
/** Explicit athlete.json URL for multi-user per-user pages. */
|
||||
export let athleteUrl: string = '';
|
||||
|
||||
let athlete: AthleteJson | null = null;
|
||||
let activities: ActivitySummary[] = [];
|
||||
@@ -34,8 +38,8 @@
|
||||
mounted = true;
|
||||
try {
|
||||
const [athleteData, index] = await Promise.all([
|
||||
loadAthlete(import.meta.env.BASE_URL),
|
||||
loadIndex(import.meta.env.BASE_URL),
|
||||
loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined),
|
||||
loadIndex(import.meta.env.BASE_URL, indexUrl || undefined),
|
||||
]);
|
||||
if (!athleteData) throw new Error('athlete.json not found — run bincio extract first');
|
||||
athlete = athleteData;
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||
import { loadIndex } from '../lib/dataloader';
|
||||
|
||||
/** Explicit index URL — use for per-user stats pages in multi-user mode. */
|
||||
export let indexUrl: string = '';
|
||||
|
||||
const PAGE_YEARS = 4;
|
||||
|
||||
let all: ActivitySummary[] = [];
|
||||
@@ -31,7 +34,7 @@
|
||||
page = parseInt(params.get('page') ?? '0', 10) || 0;
|
||||
mounted = true;
|
||||
try {
|
||||
const index = await loadIndex(import.meta.env.BASE_URL);
|
||||
const index = await loadIndex(import.meta.env.BASE_URL, indexUrl || undefined);
|
||||
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
|
||||
@@ -12,8 +12,9 @@ const { title = 'BincioActivity', description = 'Your personal activity stats',
|
||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||
|
||||
// Detect whether this instance is private (multi-user, requires login to view).
|
||||
// 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,
|
||||
@@ -24,6 +25,9 @@ try {
|
||||
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) singleHandle = handles[0] as string;
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
---
|
||||
@@ -137,13 +141,29 @@ try {
|
||||
<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>
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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">
|
||||
<!-- 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>
|
||||
{editUrl && (
|
||||
<button
|
||||
id="upload-btn"
|
||||
@@ -283,6 +303,41 @@ try {
|
||||
});
|
||||
</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();
|
||||
|
||||
// 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
|
||||
const logoutEl = document.getElementById('nav-logout');
|
||||
if (logoutEl) logoutEl.style.display = '';
|
||||
} 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>
|
||||
)}
|
||||
|
||||
{editUrl && (
|
||||
<script define:vars={{ editUrl, baseUrl }}>
|
||||
const modal = document.getElementById('upload-modal');
|
||||
|
||||
@@ -161,10 +161,16 @@ export async function loadActivity(
|
||||
/**
|
||||
* Load athlete profile. Athlete data is not stored locally yet, so this is
|
||||
* always a network fetch with a graceful null on failure.
|
||||
*
|
||||
* @param baseUrl Site base URL (used to build the default path)
|
||||
* @param athleteUrl Explicit full URL — use for per-user pages in multi-user mode
|
||||
*/
|
||||
export async function loadAthlete(baseUrl: string): Promise<Record<string, unknown> | null> {
|
||||
export async function loadAthlete(
|
||||
baseUrl: string,
|
||||
athleteUrl?: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return await fetchJSON(`${baseUrl}data/athlete.json`);
|
||||
return await fetchJSON(athleteUrl ?? `${baseUrl}data/athlete.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Build-time helpers for reading the BAS shard manifest.
|
||||
* Only import this in .astro frontmatter — it uses Node.js APIs.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
export function findDataDir(): string | null {
|
||||
const candidates = [
|
||||
process.env.BINCIO_DATA_DIR,
|
||||
resolve(process.cwd(), 'public', 'data'),
|
||||
resolve(process.cwd(), '..', 'bincio_data'),
|
||||
].filter(Boolean) as string[];
|
||||
return candidates.find(d => {
|
||||
try { readFileSync(join(d, 'index.json')); return true; } catch { return false; }
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export interface ShardHandle {
|
||||
handle: string;
|
||||
/** Shard URL as written in the manifest (relative to data root). */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function readShardHandles(): ShardHandle[] {
|
||||
try {
|
||||
const dataDir = findDataDir();
|
||||
if (!dataDir) return [];
|
||||
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
|
||||
const shards: Array<{ handle?: string; url: string }> = root.shards ?? [];
|
||||
return shards
|
||||
.filter(s => !!s.handle)
|
||||
.map(s => ({ handle: s.handle!, url: s.url }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import AthleteView from '../../components/AthleteView.svelte';
|
||||
/**
|
||||
* Legacy route — redirects to /u/{handle}/athlete/
|
||||
*/
|
||||
import { readShardHandles } from '../../lib/manifest';
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const shards = readShardHandles();
|
||||
const handle = shards[0]?.handle ?? null;
|
||||
---
|
||||
<Base title="Athlete — BincioActivity">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
|
||||
<AthleteView {base} client:load />
|
||||
</Base>
|
||||
{handle ? (
|
||||
<meta http-equiv="refresh" content={`0;url=${base}u/${handle}/athlete/`} />
|
||||
<script define:vars={{ base, handle }}>
|
||||
window.location.replace(base + 'u/' + handle + '/athlete/');
|
||||
</script>
|
||||
) : (
|
||||
<p>No data found. Run <code>bincio extract</code> first.</p>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import ActivityFeed from '../components/ActivityFeed.svelte';
|
||||
import { readShardHandles } from '../lib/manifest';
|
||||
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const shards = readShardHandles();
|
||||
const isSingleUser = shards.length === 1;
|
||||
const singleHandle = isSingleUser ? shards[0].handle : null;
|
||||
---
|
||||
<Base title="BincioActivity — Feed">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Activities</h1>
|
||||
<ActivityFeed client:load />
|
||||
</Base>
|
||||
{isSingleUser ? (
|
||||
<!-- Single-user: redirect / → /u/{handle}/ -->
|
||||
<meta http-equiv="refresh" content={`0;url=${base}u/${singleHandle}/`} />
|
||||
<script define:vars={{ base, singleHandle }}>
|
||||
window.location.replace(base + 'u/' + singleHandle + '/');
|
||||
</script>
|
||||
) : (
|
||||
<Base title="BincioActivity — Feed">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Feed</h1>
|
||||
<ActivityFeed {base} client:only="svelte" />
|
||||
</Base>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import StatsView from '../../components/StatsView.svelte';
|
||||
/**
|
||||
* Legacy route — redirects to /u/{handle}/stats/
|
||||
* In multi-user mode the user-widget script in the nav handles the correct link.
|
||||
*/
|
||||
import { readShardHandles } from '../../lib/manifest';
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const shards = readShardHandles();
|
||||
const handle = shards[0]?.handle ?? null;
|
||||
---
|
||||
<Base title="Stats — BincioActivity">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Stats</h1>
|
||||
<StatsView client:load />
|
||||
</Base>
|
||||
{handle ? (
|
||||
<meta http-equiv="refresh" content={`0;url=${base}u/${handle}/stats/`} />
|
||||
<script define:vars={{ base, handle }}>
|
||||
window.location.replace(base + 'u/' + handle + '/stats/');
|
||||
</script>
|
||||
) : (
|
||||
<p>No data found. Run <code>bincio extract</code> first.</p>
|
||||
)}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
/**
|
||||
* Per-user profile page: /u/{handle}/
|
||||
*
|
||||
* In multi-user mode, getStaticPaths reads the root index.json shard manifest
|
||||
* to discover all handles. In single-user mode this page is never generated.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import ActivityFeed from '../../components/ActivityFeed.svelte';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
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) return [];
|
||||
|
||||
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
|
||||
const shards: Array<{ handle?: string; url: string }> = root.shards ?? [];
|
||||
const handles = shards.map(s => s.handle).filter(Boolean) as string[];
|
||||
|
||||
return handles.map(handle => ({
|
||||
params: { handle },
|
||||
props: { handle, indexUrl: `${handle}/index.json` },
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { handle, indexUrl } = Astro.props as { handle: string; indexUrl: string };
|
||||
const base = import.meta.env.BASE_URL;
|
||||
---
|
||||
<Base title={`@${handle} — BincioActivity`}>
|
||||
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">@{handle}</h1>
|
||||
<p class="text-zinc-500 text-sm">Activities by this user</p>
|
||||
</div>
|
||||
<ActivityFeed {base} filterHandle={handle} profileIndexUrl={indexUrl} client:only="svelte" />
|
||||
</Base>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
/**
|
||||
* Per-user athlete page: /u/{handle}/athlete/
|
||||
*/
|
||||
import Base from '../../../../layouts/Base.astro';
|
||||
import AthleteView from '../../../../components/AthleteView.svelte';
|
||||
import { readShardHandles } from '../../../../lib/manifest';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return readShardHandles().map(({ handle }) => ({
|
||||
params: { handle },
|
||||
props: { handle },
|
||||
}));
|
||||
}
|
||||
|
||||
const { handle } = Astro.props as { handle: string };
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const mergedBase = `${base}data/${handle}/_merged/`;
|
||||
const indexUrl = `${mergedBase}index.json`;
|
||||
const athleteUrl = `${mergedBase}athlete.json`;
|
||||
---
|
||||
<Base title={`@${handle} Athlete — BincioActivity`}>
|
||||
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
|
||||
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
|
||||
<nav class="flex gap-4 mt-1 mb-6">
|
||||
<a href={`${base}u/${handle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
||||
<a href={`${base}u/${handle}/stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-[--accent]">Athlete</a>
|
||||
</nav>
|
||||
</div>
|
||||
<AthleteView {base} {indexUrl} {athleteUrl} client:only="svelte" />
|
||||
</Base>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
/**
|
||||
* Per-user profile / feed page: /u/{handle}/
|
||||
*
|
||||
* Shows only this user's activities. In multi-user mode, getStaticPaths
|
||||
* reads the root shard manifest to discover all handles.
|
||||
*/
|
||||
import Base from '../../../layouts/Base.astro';
|
||||
import ActivityFeed from '../../../components/ActivityFeed.svelte';
|
||||
import { readShardHandles } from '../../../lib/manifest';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return readShardHandles().map(({ handle, url }) => ({
|
||||
params: { handle },
|
||||
props: { handle, shardUrl: url },
|
||||
}));
|
||||
}
|
||||
|
||||
const { handle, shardUrl } = Astro.props as { handle: string; shardUrl: string };
|
||||
const base = import.meta.env.BASE_URL;
|
||||
---
|
||||
<Base title={`@${handle} — BincioActivity`}>
|
||||
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2 flex items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
|
||||
<nav class="flex gap-4 mt-1">
|
||||
<a href={`${base}u/${handle}/`} class="text-sm text-[--accent]">Feed</a>
|
||||
<a href={`${base}u/${handle}/stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<ActivityFeed {base} filterHandle={handle} profileIndexUrl={shardUrl} client:only="svelte" />
|
||||
</Base>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
/**
|
||||
* Per-user stats page: /u/{handle}/stats/
|
||||
*/
|
||||
import Base from '../../../../layouts/Base.astro';
|
||||
import StatsView from '../../../../components/StatsView.svelte';
|
||||
import { readShardHandles } from '../../../../lib/manifest';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return readShardHandles().map(({ handle }) => ({
|
||||
params: { handle },
|
||||
props: { handle },
|
||||
}));
|
||||
}
|
||||
|
||||
const { handle } = Astro.props as { handle: string };
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const indexUrl = `${base}data/${handle}/_merged/index.json`;
|
||||
---
|
||||
<Base title={`@${handle} Stats — BincioActivity`}>
|
||||
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
|
||||
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
|
||||
<nav class="flex gap-4 mt-1 mb-6">
|
||||
<a href={`${base}u/${handle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
||||
<a href={`${base}u/${handle}/stats/`} class="text-sm text-[--accent]">Stats</a>
|
||||
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
||||
</nav>
|
||||
</div>
|
||||
<StatsView {indexUrl} client:only="svelte" />
|
||||
</Base>
|
||||
Reference in New Issue
Block a user