adding community tab
This commit is contained in:
@@ -0,0 +1,348 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { BASIndex, ActivitySummary } from '../lib/types';
|
||||||
|
import { formatDistance, formatDuration, sportIcon } from '../lib/format';
|
||||||
|
|
||||||
|
export let base: string = '/';
|
||||||
|
|
||||||
|
type Period = 'week' | 'month' | 'year' | 'all';
|
||||||
|
|
||||||
|
interface UserRaw {
|
||||||
|
handle: string;
|
||||||
|
display_name: string;
|
||||||
|
activities: ActivitySummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserStat {
|
||||||
|
handle: string;
|
||||||
|
display_name: string;
|
||||||
|
count: number;
|
||||||
|
distance_m: number;
|
||||||
|
elevation_m: number;
|
||||||
|
duration_s: number;
|
||||||
|
sports: string[];
|
||||||
|
streak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Totals {
|
||||||
|
count: number;
|
||||||
|
distance_m: number;
|
||||||
|
elevation_m: number;
|
||||||
|
duration_s: number;
|
||||||
|
users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let period: Period = 'month';
|
||||||
|
let users: UserRaw[] = [];
|
||||||
|
let stats: UserStat[] = [];
|
||||||
|
let totals: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
|
||||||
|
let loading = true;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function periodStart(p: Period): Date {
|
||||||
|
const now = new Date();
|
||||||
|
if (p === 'all') return new Date(0);
|
||||||
|
if (p === 'year') return new Date(now.getFullYear(), 0, 1);
|
||||||
|
if (p === 'month') return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
// week: Monday
|
||||||
|
const d = new Date(now);
|
||||||
|
const day = d.getDay();
|
||||||
|
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max consecutive active days across all public activities (all-time). */
|
||||||
|
function maxStreak(activities: ActivitySummary[]): number {
|
||||||
|
if (!activities.length) return 0;
|
||||||
|
const days = [...new Set(activities.map(a => a.started_at.slice(0, 10)))].sort();
|
||||||
|
let max = 1, cur = 1;
|
||||||
|
for (let i = 1; i < days.length; i++) {
|
||||||
|
const diff = (new Date(days[i]).getTime() - new Date(days[i - 1]).getTime()) / 86_400_000;
|
||||||
|
cur = diff === 1 ? cur + 1 : 1;
|
||||||
|
if (cur > max) max = cur;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeStats(rawUsers: UserRaw[], p: Period): { stats: UserStat[]; totals: Totals } {
|
||||||
|
const start = periodStart(p);
|
||||||
|
const result: UserStat[] = [];
|
||||||
|
const tot: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
|
||||||
|
|
||||||
|
for (const u of rawUsers) {
|
||||||
|
const pub = u.activities.filter(a => a.privacy !== 'private');
|
||||||
|
const filtered = pub.filter(a => new Date(a.started_at) >= start);
|
||||||
|
if (filtered.length === 0) continue;
|
||||||
|
|
||||||
|
const stat: UserStat = {
|
||||||
|
handle: u.handle,
|
||||||
|
display_name: u.display_name,
|
||||||
|
count: filtered.length,
|
||||||
|
distance_m: filtered.reduce((s, a) => s + (a.distance_m ?? 0), 0),
|
||||||
|
elevation_m: filtered.reduce((s, a) => s + (a.elevation_gain_m ?? 0), 0),
|
||||||
|
duration_s: filtered.reduce((s, a) => s + (a.duration_s ?? 0), 0),
|
||||||
|
sports: [...new Set(filtered.map(a => a.sport))],
|
||||||
|
streak: maxStreak(pub),
|
||||||
|
};
|
||||||
|
|
||||||
|
tot.count += stat.count;
|
||||||
|
tot.distance_m += stat.distance_m;
|
||||||
|
tot.elevation_m += stat.elevation_m;
|
||||||
|
tot.duration_s += stat.duration_s;
|
||||||
|
tot.users++;
|
||||||
|
|
||||||
|
result.push(stat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stats: result, totals: tot };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchShard(url: string): Promise<ActivitySummary[]> {
|
||||||
|
const data: BASIndex = await fetch(url).then(r => { if (!r.ok) throw new Error(String(r.status)); return r.json(); });
|
||||||
|
const own = data.activities ?? [];
|
||||||
|
if (!data.shards?.length) return own;
|
||||||
|
const shardBase = url.substring(0, url.lastIndexOf('/') + 1);
|
||||||
|
const nested = await Promise.allSettled(
|
||||||
|
data.shards.map(s => fetchShard(s.url.startsWith('http') ? s.url : `${shardBase}${s.url}`))
|
||||||
|
);
|
||||||
|
return [...own, ...nested.flatMap(r => r.status === 'fulfilled' ? r.value : [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const rootUrl = `${base}data/index.json`;
|
||||||
|
const root: BASIndex = await fetch(rootUrl).then(r => r.json());
|
||||||
|
|
||||||
|
const userShards = (root.shards ?? []).filter(s => s.handle);
|
||||||
|
if (userShards.length === 0) {
|
||||||
|
error = 'No community members found.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
userShards.map(async shard => {
|
||||||
|
const shardBase = `${base}data/`;
|
||||||
|
const url = shard.url.startsWith('http') ? shard.url : `${shardBase}${shard.url}`;
|
||||||
|
const shardIndex: BASIndex = await fetch(url).then(r => r.json());
|
||||||
|
const activities = await fetchShard(url);
|
||||||
|
return {
|
||||||
|
handle: shard.handle!,
|
||||||
|
display_name: shardIndex.owner?.display_name ?? shard.handle!,
|
||||||
|
activities,
|
||||||
|
} as UserRaw;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
users = results.flatMap(r => r.status === 'fulfilled' ? [r.value] : []);
|
||||||
|
({ stats, totals } = computeStats(users, period));
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (users.length) {
|
||||||
|
({ stats, totals } = computeStats(users, period));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
|
||||||
|
// ── Ranking helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function top3<K extends keyof UserStat>(key: K, min = 0): UserStat[] {
|
||||||
|
return [...stats]
|
||||||
|
.filter(u => (u[key] as number) > min)
|
||||||
|
.sort((a, b) => (b[key] as number) - (a[key] as number))
|
||||||
|
.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function top3Sports(): UserStat[] {
|
||||||
|
return [...stats]
|
||||||
|
.filter(u => u.sports.length > 1)
|
||||||
|
.sort((a, b) => b.sports.length - a.sports.length)
|
||||||
|
.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDAL = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
$: rowsCount = top3('count', 0);
|
||||||
|
$: rowsDistance = top3('distance_m', 0);
|
||||||
|
$: rowsElevation = top3('elevation_m', 0);
|
||||||
|
$: rowsDuration = top3('duration_s', 0);
|
||||||
|
$: rowsSports = top3Sports();
|
||||||
|
$: rowsStreak = [...stats].filter(u => u.streak > 1).sort((a, b) => b.streak - a.streak).slice(0, 3);
|
||||||
|
|
||||||
|
const PERIODS: { key: Period; label: string }[] = [
|
||||||
|
{ key: 'week', label: 'This week' },
|
||||||
|
{ key: 'month', label: 'This month' },
|
||||||
|
{ key: 'year', label: 'This year' },
|
||||||
|
{ key: 'all', label: 'All time' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-1">Community</h1>
|
||||||
|
<p class="text-zinc-400 text-sm">What everyone's been up to — together.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-zinc-400 text-sm">Loading…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-400 text-sm">{error}</p>
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<!-- Period selector -->
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
{#each PERIODS as p}
|
||||||
|
<button
|
||||||
|
on:click={() => period = p.key}
|
||||||
|
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors"
|
||||||
|
class:bg-blue-500={period === p.key}
|
||||||
|
class:border-blue-500={period === p.key}
|
||||||
|
class:text-white={period === p.key}
|
||||||
|
class:border-zinc-700={period !== p.key}
|
||||||
|
class:text-zinc-400={period !== p.key}
|
||||||
|
class:hover:text-white={period !== p.key}
|
||||||
|
>{p.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Community totals banner -->
|
||||||
|
{#if totals.users > 0}
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{#each [
|
||||||
|
{ label: 'Activities', value: totals.count.toLocaleString() },
|
||||||
|
{ label: 'Distance', value: formatDistance(totals.distance_m) },
|
||||||
|
{ label: 'Elevation', value: `${Math.round(totals.elevation_m / 1000).toLocaleString()} km↑` },
|
||||||
|
{ label: 'Time', value: formatDuration(totals.duration_s) },
|
||||||
|
] as item}
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 text-center">
|
||||||
|
<div class="text-xl font-bold text-white">{item.value}</div>
|
||||||
|
<div class="text-xs text-zinc-500 mt-0.5">{item.label}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Leaderboard sections -->
|
||||||
|
{#if totals.users === 0}
|
||||||
|
<p class="text-zinc-500 text-sm">No public activities in this period yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<!-- Out there -->
|
||||||
|
{#if rowsCount.length}
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Out there</h2>
|
||||||
|
<p class="text-xs text-zinc-500 -mt-1">Who kept showing up</p>
|
||||||
|
{#each rowsCount as u, i}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-base leading-none">{MEDAL[i]}</span>
|
||||||
|
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-white ml-2 shrink-0">{u.count} {u.count === 1 ? 'activity' : 'activities'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Going far -->
|
||||||
|
{#if rowsDistance.length}
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Going far</h2>
|
||||||
|
<p class="text-xs text-zinc-500 -mt-1">Who covered the most ground</p>
|
||||||
|
{#each rowsDistance as u, i}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-base leading-none">{MEDAL[i]}</span>
|
||||||
|
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-white ml-2 shrink-0">{formatDistance(u.distance_m)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Reaching new heights -->
|
||||||
|
{#if rowsElevation.length}
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Reaching new heights</h2>
|
||||||
|
<p class="text-xs text-zinc-500 -mt-1">Who climbed the most</p>
|
||||||
|
{#each rowsElevation as u, i}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-base leading-none">{MEDAL[i]}</span>
|
||||||
|
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-white ml-2 shrink-0">{Math.round(u.elevation_m).toLocaleString()} m</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Hours on the move -->
|
||||||
|
{#if rowsDuration.length}
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Hours on the move</h2>
|
||||||
|
<p class="text-xs text-zinc-500 -mt-1">Who invested the most time</p>
|
||||||
|
{#each rowsDuration as u, i}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-base leading-none">{MEDAL[i]}</span>
|
||||||
|
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-white ml-2 shrink-0">{formatDuration(u.duration_s)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Explorer -->
|
||||||
|
{#if rowsSports.length}
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Explorer</h2>
|
||||||
|
<p class="text-xs text-zinc-500 -mt-1">Who tried the most sports</p>
|
||||||
|
{#each rowsSports as u, i}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-base leading-none">{MEDAL[i]}</span>
|
||||||
|
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-white ml-2 shrink-0 flex gap-1">
|
||||||
|
{#each u.sports as s}{sportIcon(s)}{/each}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Longest streak (all-time, shown in all periods as context) -->
|
||||||
|
{#if rowsStreak.length}
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Never stopped</h2>
|
||||||
|
<p class="text-xs text-zinc-500 -mt-1">Longest streak of consecutive days (all time)</p>
|
||||||
|
{#each rowsStreak as u, i}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-base leading-none">{MEDAL[i]}</span>
|
||||||
|
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-white ml-2 shrink-0">{u.streak} days</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -176,6 +176,9 @@ try {
|
|||||||
<!-- Per-user nav links — updated by user-widget script in multi-user mode -->
|
<!-- 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 shrink-0">Stats</a>
|
<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 shrink-0">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 shrink-0">Athlete</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 shrink-0">Athlete</a>
|
||||||
|
{!singleHandle && (
|
||||||
|
<a href={`${baseUrl}community/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Community</a>
|
||||||
|
)}
|
||||||
{mobileApp && (
|
{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 shrink-0">Record</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 shrink-0">Record</a>
|
||||||
)}
|
)}
|
||||||
@@ -183,7 +186,6 @@ try {
|
|||||||
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Convert</a>
|
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Convert</a>
|
||||||
)}
|
)}
|
||||||
<a href={`${baseUrl}about/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">About</a>
|
<a href={`${baseUrl}about/`} class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">About</a>
|
||||||
<a id="nav-feedback" href={`${baseUrl}feedback/`} style="display:none" class="text-sm text-zinc-400 hover:text-white transition-colors shrink-0">Feedback</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -423,11 +425,9 @@ try {
|
|||||||
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
|
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show logout button and feedback link
|
// Show logout button
|
||||||
const logoutEl = document.getElementById('nav-logout');
|
const logoutEl = document.getElementById('nav-logout');
|
||||||
if (logoutEl) logoutEl.style.display = '';
|
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
|
// Pre-populate the "keep original" checkbox from the instance default
|
||||||
const chk = document.getElementById('upload-keep-original');
|
const chk = document.getElementById('upload-keep-original');
|
||||||
|
|||||||
@@ -23,15 +23,25 @@ const labels = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-zinc-500 mb-4">Seguiment d'activitats de codi obert i allotjament propi</p>
|
<p class="text-sm text-zinc-500 mb-4">Seguiment d'activitats de codi obert i allotjament propi</p>
|
||||||
<a
|
<div class="flex flex-wrap gap-2 mb-8">
|
||||||
href="https://ko-fi.com/brutsalvadi"
|
<a
|
||||||
target="_blank"
|
href="https://ko-fi.com/brutsalvadi"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
class="inline-flex items-center gap-2 mb-8 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
rel="noopener noreferrer"
|
||||||
style="background:#FF5E5B; color:#fff;"
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
||||||
>
|
style="background:#FF5E5B; color:#fff;"
|
||||||
☕ Dona suport a Ko-fi
|
>
|
||||||
</a>
|
☕ Dona suport a Ko-fi
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
id="feedback-btn"
|
||||||
|
href="/feedback/"
|
||||||
|
style="display:none"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors"
|
||||||
|
>
|
||||||
|
💬 Envia comentaris
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
||||||
|
|
||||||
@@ -146,6 +156,8 @@ const labels = {
|
|||||||
try {
|
try {
|
||||||
const me = await fetch('/api/me', { credentials: 'include' });
|
const me = await fetch('/api/me', { credentials: 'include' });
|
||||||
if (!me.ok) return;
|
if (!me.ok) return;
|
||||||
|
const feedbackBtn = document.getElementById('feedback-btn');
|
||||||
|
if (feedbackBtn) feedbackBtn.style.display = '';
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,15 +23,25 @@ const labels = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-zinc-500 mb-4">Seguimiento de actividades open-source y autoalojado</p>
|
<p class="text-sm text-zinc-500 mb-4">Seguimiento de actividades open-source y autoalojado</p>
|
||||||
<a
|
<div class="flex flex-wrap gap-2 mb-8">
|
||||||
href="https://ko-fi.com/brutsalvadi"
|
<a
|
||||||
target="_blank"
|
href="https://ko-fi.com/brutsalvadi"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
class="inline-flex items-center gap-2 mb-8 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
rel="noopener noreferrer"
|
||||||
style="background:#FF5E5B; color:#fff;"
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
||||||
>
|
style="background:#FF5E5B; color:#fff;"
|
||||||
☕ Apoya en Ko-fi
|
>
|
||||||
</a>
|
☕ Apoya en Ko-fi
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
id="feedback-btn"
|
||||||
|
href="/feedback/"
|
||||||
|
style="display:none"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors"
|
||||||
|
>
|
||||||
|
💬 Enviar comentarios
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
||||||
|
|
||||||
@@ -145,6 +155,8 @@ const labels = {
|
|||||||
try {
|
try {
|
||||||
const me = await fetch('/api/me', { credentials: 'include' });
|
const me = await fetch('/api/me', { credentials: 'include' });
|
||||||
if (!me.ok) return;
|
if (!me.ok) return;
|
||||||
|
const feedbackBtn = document.getElementById('feedback-btn');
|
||||||
|
if (feedbackBtn) feedbackBtn.style.display = '';
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -24,15 +24,25 @@ const labels = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-zinc-500 mb-4">Open-source, self-hosted activity tracking</p>
|
<p class="text-sm text-zinc-500 mb-4">Open-source, self-hosted activity tracking</p>
|
||||||
<a
|
<div class="flex flex-wrap gap-2 mb-8">
|
||||||
href="https://ko-fi.com/brutsalvadi"
|
<a
|
||||||
target="_blank"
|
href="https://ko-fi.com/brutsalvadi"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
class="inline-flex items-center gap-2 mb-8 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
rel="noopener noreferrer"
|
||||||
style="background:#FF5E5B; color:#fff;"
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
||||||
>
|
style="background:#FF5E5B; color:#fff;"
|
||||||
☕ Support on Ko-fi
|
>
|
||||||
</a>
|
☕ Support on Ko-fi
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
id="feedback-btn"
|
||||||
|
href="/feedback/"
|
||||||
|
style="display:none"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors"
|
||||||
|
>
|
||||||
|
💬 Send feedback
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
||||||
|
|
||||||
@@ -142,6 +152,8 @@ const labels = {
|
|||||||
try {
|
try {
|
||||||
const me = await fetch('/api/me', { credentials: 'include' });
|
const me = await fetch('/api/me', { credentials: 'include' });
|
||||||
if (!me.ok) return; // not logged in — hide community section
|
if (!me.ok) return; // not logged in — hide community section
|
||||||
|
const feedbackBtn = document.getElementById('feedback-btn');
|
||||||
|
if (feedbackBtn) feedbackBtn.style.display = '';
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,15 +23,25 @@ const labels = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-zinc-500 mb-4">Tracciamento attività open-source e self-hosted</p>
|
<p class="text-sm text-zinc-500 mb-4">Tracciamento attività open-source e self-hosted</p>
|
||||||
<a
|
<div class="flex flex-wrap gap-2 mb-8">
|
||||||
href="https://ko-fi.com/brutsalvadi"
|
<a
|
||||||
target="_blank"
|
href="https://ko-fi.com/brutsalvadi"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
class="inline-flex items-center gap-2 mb-8 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
rel="noopener noreferrer"
|
||||||
style="background:#FF5E5B; color:#fff;"
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
||||||
>
|
style="background:#FF5E5B; color:#fff;"
|
||||||
☕ Supporta su Ko-fi
|
>
|
||||||
</a>
|
☕ Supporta su Ko-fi
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
id="feedback-btn"
|
||||||
|
href="/feedback/"
|
||||||
|
style="display:none"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors"
|
||||||
|
>
|
||||||
|
💬 Invia feedback
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
||||||
|
|
||||||
@@ -145,6 +155,8 @@ const labels = {
|
|||||||
try {
|
try {
|
||||||
const me = await fetch('/api/me', { credentials: 'include' });
|
const me = await fetch('/api/me', { credentials: 'include' });
|
||||||
if (!me.ok) return;
|
if (!me.ok) return;
|
||||||
|
const feedbackBtn = document.getElementById('feedback-btn');
|
||||||
|
if (feedbackBtn) feedbackBtn.style.display = '';
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import CommunityView from '../../components/CommunityView.svelte';
|
||||||
|
---
|
||||||
|
<Base title="Community — BincioActivity">
|
||||||
|
<CommunityView base={import.meta.env.BASE_URL} client:only="svelte" />
|
||||||
|
</Base>
|
||||||
Reference in New Issue
Block a user