feat: Support page with budget transparency (replaces About)

This commit is contained in:
Davide Scaini
2026-06-03 10:34:18 +02:00
parent b781193d44
commit fa14d91359
5 changed files with 827 additions and 254 deletions
+131
View File
@@ -0,0 +1,131 @@
"""Budget transparency endpoints (/api/budget)."""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from bincio.serve import deps
from bincio.serve.db import User
router = APIRouter()
_BUDGET_FILE = "budget.json"
def _budget_path() -> Path:
return deps._get_data_dir() / _BUDGET_FILE
def _load() -> dict:
p = _budget_path()
if not p.exists():
return {"monthly_target_eur": None, "entries": []}
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {"monthly_target_eur": None, "entries": []}
def _save(data: dict) -> None:
_budget_path().write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
@router.get("/api/budget")
async def get_budget() -> JSONResponse:
return JSONResponse(_load())
@router.post("/api/budget/settings")
async def update_settings(
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
data = _load()
if "monthly_target_eur" in body:
v = body["monthly_target_eur"]
data["monthly_target_eur"] = round(float(v), 2) if v is not None else None
_save(data)
return JSONResponse({"ok": True})
@router.post("/api/budget/entries")
async def add_entry(
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
entry_type = body.get("type")
if entry_type not in ("donation", "expense"):
raise HTTPException(400, "type must be 'donation' or 'expense'")
label = str(body.get("label", "")).strip()
if not label:
raise HTTPException(400, "label is required")
try:
amount = round(float(body["amount_eur"]), 2)
except (KeyError, TypeError, ValueError):
raise HTTPException(400, "amount_eur must be a number")
month = str(body.get("month", "")).strip()
if len(month) != 7 or month[4] != "-":
raise HTTPException(400, "month must be YYYY-MM")
note = str(body.get("note", "")).strip()
entry = {
"id": str(uuid.uuid4())[:8],
"type": entry_type,
"label": label,
"amount_eur": amount,
"month": month,
"note": note,
}
data = _load()
data.setdefault("entries", []).append(entry)
_save(data)
return JSONResponse(entry, status_code=201)
@router.patch("/api/budget/entries/{entry_id}")
async def update_entry(
entry_id: str,
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
data = _load()
entry = next((e for e in data.get("entries", []) if e["id"] == entry_id), None)
if not entry:
raise HTTPException(404, "Entry not found")
if "label" in body:
entry["label"] = str(body["label"]).strip()
if "type" in body:
if body["type"] not in ("donation", "expense"):
raise HTTPException(400, "type must be 'donation' or 'expense'")
entry["type"] = body["type"]
if "amount_eur" in body:
entry["amount_eur"] = round(float(body["amount_eur"]), 2)
if "month" in body:
entry["month"] = str(body["month"]).strip()
if "note" in body:
entry["note"] = str(body["note"]).strip()
_save(data)
return JSONResponse(entry)
@router.delete("/api/budget/entries/{entry_id}")
async def delete_entry(
entry_id: str,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
data = _load()
before = len(data.get("entries", []))
data["entries"] = [e for e in data.get("entries", []) if e["id"] != entry_id]
if len(data["entries"]) == before:
raise HTTPException(404, "Entry not found")
_save(data)
return JSONResponse({"ok": True})
+1 -1
View File
@@ -55,7 +55,7 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origin_regex=r"https?://localhost(:\d+)?|https://[a-z0-9-]+\.bincio\.org", allow_origin_regex=r"https?://localhost(:\d+)?|https://[a-z0-9-]+\.bincio\.org",
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "DELETE"], allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type"], allow_headers=["Content-Type"],
) )
+4 -4
View File
@@ -263,9 +263,9 @@ try {
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1" class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
>Ideas</a> >Ideas</a>
<a <a
href={`${baseUrl}about/`} href={`${baseUrl}support/`}
class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1" class="hidden sm:inline text-xs text-zinc-500 hover:text-white transition-colors px-1"
>About</a> >Support</a>
<!-- Settings link — hidden until logged in --> <!-- Settings link — hidden until logged in -->
<a <a
id="nav-settings" id="nav-settings"
@@ -333,9 +333,9 @@ try {
<a href={`${baseUrl}ideas/`} <a href={`${baseUrl}ideas/`}
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors" class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
style="color: var(--text-4)">Ideas</a> style="color: var(--text-4)">Ideas</a>
<a href={`${baseUrl}about/`} <a href={`${baseUrl}support/`}
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors" class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
style="color: var(--text-4)">About</a> style="color: var(--text-4)">Support</a>
<a href={`${baseUrl}settings/`} <a href={`${baseUrl}settings/`}
class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors" class="text-sm px-2 py-1.5 rounded hover:bg-zinc-800 transition-colors"
style="color: var(--text-4)">Settings</a> style="color: var(--text-4)">Settings</a>
+3 -249
View File
@@ -1,252 +1,6 @@
--- ---
import Base from '../../layouts/Base.astro';
const baseUrl = import.meta.env.BASE_URL ?? '/'; const baseUrl = import.meta.env.BASE_URL ?? '/';
const labels = { const target = `${baseUrl}support/`;
community: 'Community',
members: 'member',
members_pl: 'members',
day: 'day',
days: 'days',
invited_by: 'invited by',
founder: 'founder',
loading: 'Loading…',
};
--- ---
<Base title="About — BincioActivity" public={true}> <meta http-equiv="refresh" content={`0;url=${target}`} />
<div class="max-w-2xl mx-auto"> <script define:vars={{ target }}>window.location.replace(target);</script>
<div class="flex items-baseline justify-between mb-1">
<h1 class="text-2xl font-bold text-white">About BincioActivity</h1>
<div class="flex gap-3 text-xs text-zinc-500">
<span class="text-zinc-300 font-medium">EN</span>
<a href={`${baseUrl}about/it/`} class="hover:text-white transition-colors">IT</a>
<a href={`${baseUrl}about/es/`} class="hover:text-white transition-colors">ES</a>
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
</div>
</div>
<p class="text-sm text-zinc-500 mb-4">Open-source, self-hosted activity tracking</p>
<div class="flex flex-wrap gap-2 mb-4">
<a
href="https://ko-fi.com/brutsalvadi"
target="_blank"
rel="noopener noreferrer"
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>
<a
href="https://web.satispay.com/download/qrcode/S6Y-CON--BE9BD345-4499-4C1D-9AC3-D62FC5FF0AD4"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
style="background:#E3162C; color:#fff;"
>
Satispay @brutsalvadi
</a>
</div>
<div class="mb-8">
<img
src="/satispay-qr.jpg"
alt="Satispay QR code — @brutsalvadi"
class="w-36 h-36 rounded-xl"
/>
</div>
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
<!-- Community stats (shown only in multi-user mode) -->
<section id="stats-section" style="display:none">
<h2 class="text-base font-semibold text-white mb-3">Community</h2>
<p id="stats-summary" class="text-zinc-500 text-xs mb-4"></p>
<div id="stats-tree" class="text-sm"></div>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">What is this?</h2>
<p>
BincioActivity is a free, open-source platform for tracking your outdoor activities —
cycling, running, hiking, and more. It is designed to be self-hosted: you (or someone
you trust) run the server, and your data stays under your control.
</p>
<p class="mt-2">
Activities are stored in an open JSON format called BAS (BincioActivity Schema),
which is designed to be readable and portable. The platform has no hidden analytics,
no advertising, and no third-party data sharing.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Joining &amp; invitations</h2>
<p>
This instance is invite-only. To join, you need an invite link from an existing
member — each link is single-use and tied to a unique code.
</p>
<p class="mt-2">
Once you have an account, you can generate up to <strong class="text-zinc-300">3 invite links</strong> to
share with people you trust. You can manage your invites from the <a id="invites-link" href="invites/" class="text-blue-400 hover:text-blue-300 transition-colors">invites page</a>
(requires login).
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Your data on this server</h2>
<p>
When you upload a FIT, GPX, or TCX file, the server converts it to BAS format.
By default the original source file is also kept in your account's
<code class="text-zinc-300 bg-zinc-800 px-1 rounded">originals/</code> folder.
You can opt out of this at upload time by unchecking <em>"Keep original file on server"</em>.
</p>
<p class="mt-2">
Keeping originals is recommended during these early stages of the project: if the
processing pipeline improves (better elevation smoothing, speed calculation, lap
detection, etc.) you can re-import your files to take advantage of the changes.
If you chose not to keep originals, you would need to upload the files again manually.
</p>
<p class="mt-2">
When syncing from Strava, the raw activity data fetched from the Strava API can
similarly be stored locally. This is controlled by an instance-wide setting
configured by the server operator.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Early-stage software</h2>
<p>
BincioActivity is under active development. The data format, processing pipeline,
and server API may change between versions. Breaking changes are possible, especially
at this stage. When they occur, re-importing your original files is the safest way
to bring your data up to date.
</p>
<p class="mt-2">
There is no guarantee of uptime, data integrity, or forward compatibility for
any particular version. Use this software at your own risk, and keep your own
backups of important data.
</p>
</section>
<section class="border border-zinc-800 rounded-xl p-4 bg-zinc-900/50">
<h2 class="text-base font-semibold text-white mb-2">Disclaimer</h2>
<p>
BincioActivity is provided <strong class="text-zinc-300">"as is"</strong>, without
warranty of any kind. The authors and server operators accept no responsibility for:
</p>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Loss, corruption, or unauthorised access to your activity data</li>
<li>Data exposed through misconfiguration of the server or infrastructure</li>
<li>Inaccuracies in computed statistics (distance, elevation, heart rate, etc.)</li>
<li>Any consequences of acting on information displayed by this application</li>
</ul>
<p class="mt-3">
You are responsible for securing your account with a strong password, reviewing
what data you share, and making your own backups. GPS and health data can be
sensitive — think carefully about what you upload and who can see it.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Open source</h2>
<p>
BincioActivity is open-source software. You are free to inspect the code,
self-host your own instance, and contribute improvements.
</p>
</section>
</div>
</div>
</Base>
<script define:vars={{ labels }}>
(async () => {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return; // not logged in — hide community section
} catch { return; }
let data;
try {
const r = await fetch('/api/stats');
if (!r.ok) return; // single-user mode — no stats endpoint
data = await r.json();
} catch { return; }
// Fix invites link to use absolute base URL
const invLink = document.getElementById('invites-link');
if (invLink) invLink.href = '/invites/';
if (!data.user_count) return;
const section = document.getElementById('stats-section');
const summary = document.getElementById('stats-summary');
const treeEl = document.getElementById('stats-tree');
const n = data.user_count;
summary.textContent = `${n} ${n === 1 ? labels.members : labels.members_pl}`;
section.style.display = '';
// Build adjacency map: handle → [children]
const byHandle = {};
for (const m of data.members) byHandle[m.handle] = m;
const children = {};
const roots = [];
for (const m of data.members) {
if (m.invited_by && byHandle[m.invited_by]) {
(children[m.invited_by] ??= []).push(m.handle);
} else {
roots.push(m.handle);
}
}
function formatDuration(days) {
if (days < 1) return `< 1 ${labels.day}`;
if (days === 1) return `1 ${labels.day}`;
if (days < 30) return `${days} ${labels.days}`;
const months = Math.floor(days / 30);
return months === 1 ? `1 mo` : `${months} mo`;
}
function renderNode(handle, depth) {
const m = byHandle[handle];
const indent = depth * 20;
const isRoot = !m.invited_by;
const sub = isRoot
? labels.founder
: `${labels.invited_by} @${m.invited_by}`;
const row = document.createElement('div');
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
row.style.paddingLeft = `${indent}px`;
if (depth > 0) {
const connector = document.createElement('span');
connector.className = 'text-zinc-700 shrink-0';
connector.textContent = '└';
row.appendChild(connector);
}
const name = document.createElement('span');
name.className = 'text-white font-medium';
name.textContent = m.display_name || `@${handle}`;
row.appendChild(name);
const handle_el = document.createElement('span');
handle_el.className = 'text-zinc-600 text-xs';
handle_el.textContent = `@${handle}`;
row.appendChild(handle_el);
const spacer = document.createElement('span');
spacer.className = 'flex-1';
row.appendChild(spacer);
const meta = document.createElement('span');
meta.className = 'text-zinc-600 text-xs text-right shrink-0';
meta.innerHTML = `${formatDuration(m.member_for_days)}<br><span class="text-zinc-700">${sub}</span>`;
row.appendChild(meta);
treeEl.appendChild(row);
for (const child of (children[handle] ?? [])) {
renderNode(child, depth + 1);
}
}
for (const root of roots) renderNode(root, 0);
})();
</script>
+688
View File
@@ -0,0 +1,688 @@
---
import Base from '../../layouts/Base.astro';
const baseUrl = import.meta.env.BASE_URL ?? '/';
---
<Base title="Support — BincioActivity" public={true}>
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-1">Support</h1>
<p class="text-sm text-zinc-500 mb-5">Open-source, self-hosted activity tracking</p>
<!-- Tab bar -->
<div class="flex gap-1 border-b border-zinc-800 mb-6">
<button data-tab="about" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors">About</button>
<button data-tab="donate" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors">Donate</button>
<button data-tab="feedback" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors">Feedback</button>
</div>
<!-- ── ABOUT ──────────────────────────────────────────────────────── -->
<div id="tab-about" class="tab-panel">
<div class="flex justify-end gap-3 text-xs text-zinc-500 mb-5">
<span class="text-zinc-300 font-medium">EN</span>
<a href={`${baseUrl}about/it/`} class="hover:text-white transition-colors">IT</a>
<a href={`${baseUrl}about/es/`} class="hover:text-white transition-colors">ES</a>
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
</div>
<!-- Community stats (multi-user only) -->
<section id="stats-section" class="mb-8" style="display:none">
<h2 class="text-base font-semibold text-white mb-3">Community</h2>
<p id="stats-summary" class="text-zinc-500 text-xs mb-4"></p>
<div id="stats-tree" class="text-sm"></div>
</section>
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
<section>
<h2 class="text-base font-semibold text-white mb-2">What is this?</h2>
<p>
BincioActivity is a free, open-source platform for tracking your outdoor activities —
cycling, running, hiking, and more. It is designed to be self-hosted: you (or someone
you trust) run the server, and your data stays under your control.
</p>
<p class="mt-2">
Activities are stored in an open JSON format called BAS (BincioActivity Schema),
which is designed to be readable and portable. The platform has no hidden analytics,
no advertising, and no third-party data sharing.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Joining &amp; invitations</h2>
<p>
This instance is invite-only. To join, you need an invite link from an existing
member — each link is single-use and tied to a unique code.
</p>
<p class="mt-2">
Once you have an account, you can generate up to <strong class="text-zinc-300">3 invite links</strong> to
share with people you trust. You can manage your invites from the
<a href="/invites/" class="text-blue-400 hover:text-blue-300 transition-colors">invites page</a>
(requires login).
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Your data on this server</h2>
<p>
When you upload a FIT, GPX, or TCX file, the server converts it to BAS format.
By default the original source file is also kept in your account's
<code class="text-zinc-300 bg-zinc-800 px-1 rounded">originals/</code> folder.
You can opt out of this at upload time by unchecking <em>"Keep original file on server"</em>.
</p>
<p class="mt-2">
Keeping originals is recommended during these early stages of the project: if the
processing pipeline improves (better elevation smoothing, speed calculation, lap
detection, etc.) you can re-import your files to take advantage of the changes.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Early-stage software</h2>
<p>
BincioActivity is under active development. The data format, processing pipeline,
and server API may change between versions. Breaking changes are possible, especially
at this stage. When they occur, re-importing your original files is the safest way
to bring your data up to date.
</p>
<p class="mt-2">
There is no guarantee of uptime, data integrity, or forward compatibility for
any particular version. Use this software at your own risk, and keep your own
backups of important data.
</p>
</section>
<section class="border border-zinc-800 rounded-xl p-4 bg-zinc-900/50">
<h2 class="text-base font-semibold text-white mb-2">Disclaimer</h2>
<p>
BincioActivity is provided <strong class="text-zinc-300">"as is"</strong>, without
warranty of any kind. The authors and server operators accept no responsibility for:
</p>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Loss, corruption, or unauthorised access to your activity data</li>
<li>Data exposed through misconfiguration of the server or infrastructure</li>
<li>Inaccuracies in computed statistics (distance, elevation, heart rate, etc.)</li>
<li>Any consequences of acting on information displayed by this application</li>
</ul>
<p class="mt-3">
You are responsible for securing your account with a strong password, reviewing
what data you share, and making your own backups. GPS and health data can be
sensitive — think carefully about what you upload and who can see it.
</p>
</section>
<section>
<h2 class="text-base font-semibold text-white mb-2">Open source</h2>
<p>
BincioActivity is open-source software. You are free to inspect the code,
self-host your own instance, and contribute improvements.
</p>
</section>
</div>
</div>
<!-- ── DONATE ─────────────────────────────────────────────────────── -->
<div id="tab-donate" class="tab-panel" style="display:none">
<div class="flex flex-wrap gap-2 mb-4">
<a href="https://ko-fi.com/brutsalvadi" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
style="background:#FF5E5B; color:#fff;">
☕ Support on Ko-fi
</a>
<a href="https://web.satispay.com/download/qrcode/S6Y-CON--BE9BD345-4499-4C1D-9AC3-D62FC5FF0AD4"
target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
style="background:#E3162C; color:#fff;">
Satispay @brutsalvadi
</a>
</div>
<div class="mb-8">
<img src="/satispay-qr.jpg" alt="Satispay QR code — @brutsalvadi" class="w-36 h-36 rounded-xl" />
</div>
<!-- Budget section -->
<div class="border-t border-zinc-800 pt-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">Running costs</h2>
<p class="text-xs text-zinc-500 mt-0.5">Transparent breakdown of donations and expenses</p>
</div>
<!-- Admin controls -->
<div id="budget-admin-controls" class="hidden flex gap-2">
<button id="budget-set-goal-btn"
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-white text-xs font-medium transition-colors border border-zinc-700">
Set monthly goal
</button>
<button id="budget-add-btn"
class="px-3 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-xs font-medium transition-opacity">
+ Add entry
</button>
</div>
</div>
<!-- Set-goal form (admin, hidden by default) -->
<form id="budget-goal-form" class="hidden mb-4 p-3 rounded-xl bg-zinc-900 border border-zinc-800 text-sm">
<label class="block text-zinc-400 mb-1 text-xs">Monthly goal (€)</label>
<div class="flex gap-2">
<input id="budget-goal-input" type="number" min="0" step="0.01" placeholder="e.g. 20"
class="flex-1 px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
<button type="submit" class="px-4 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">Save</button>
<button type="button" id="budget-goal-cancel" class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-sm transition-colors">Cancel</button>
</div>
<p id="budget-goal-err" class="text-red-400 text-xs mt-1 hidden"></p>
</form>
<!-- Add-entry form (admin, hidden by default) -->
<form id="budget-add-form" class="hidden mb-4 p-3 rounded-xl bg-zinc-900 border border-zinc-800 text-sm space-y-2">
<div class="flex gap-2">
<button type="button" data-type="donation" class="entry-type-btn flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors">💚 Donation</button>
<button type="button" data-type="expense" class="entry-type-btn flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors">🔴 Expense</button>
</div>
<input id="entry-label" type="text" placeholder="Label (e.g. VPS hosting)"
class="w-full px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
<div class="flex gap-2">
<div class="flex-1 relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">€</span>
<input id="entry-amount" type="number" min="0" step="0.01" placeholder="0.00"
class="w-full pl-7 pr-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
</div>
<input id="entry-month" type="month"
class="flex-1 px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent] [color-scheme:dark]" />
</div>
<input id="entry-note" type="text" placeholder="Note (optional)"
class="w-full px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
<p id="budget-add-err" class="text-red-400 text-xs hidden"></p>
<div class="flex gap-2 pt-1">
<button type="submit" class="px-4 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">Add</button>
<button type="button" id="budget-add-cancel" class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-sm transition-colors">Cancel</button>
</div>
</form>
<!-- Budget list -->
<div id="budget-list">
<p class="text-zinc-500 text-sm text-center py-6">Loading…</p>
</div>
</div>
</div>
<!-- ── FEEDBACK ───────────────────────────────────────────────────── -->
<div id="tab-feedback" class="tab-panel" style="display:none">
<h2 class="text-base font-semibold text-white mb-1">Send feedback</h2>
<p class="text-sm text-zinc-500 mb-5">Report a bug, suggest a feature, or share anything useful.</p>
<form id="feedback-form" class="space-y-4">
<textarea id="fb-text" rows="6" placeholder="What's on your mind?"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-[--accent] resize-none"></textarea>
<div>
<p class="text-xs text-zinc-500 mb-2">Attach up to 3 screenshots (max 2 MB each)</p>
<div id="fb-drop"
class="border-2 border-dashed border-zinc-700 rounded-lg p-5 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors">
<span id="fb-drop-label">Drop images or click to browse</span>
<input id="fb-input" type="file" accept="image/*" multiple class="hidden" />
</div>
<div id="fb-previews" class="flex gap-2 flex-wrap mt-2"></div>
</div>
<p id="fb-error" class="text-red-400 text-sm hidden"></p>
<button type="submit"
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium text-sm transition-opacity">
Send feedback
</button>
</form>
<div id="fb-success" class="hidden text-center mt-12">
<p class="text-2xl mb-2">Thanks!</p>
<p class="text-zinc-400 text-sm">Your feedback has been received.</p>
</div>
</div>
</div>
</Base>
<script>
// ── Tabs ──────────────────────────────────────────────────────────────────────
const TABS = ['about', 'donate', 'feedback'] as const;
type TabName = typeof TABS[number];
function showTab(name: TabName) {
TABS.forEach(t => {
const panel = document.getElementById(`tab-${t}`)!;
const btn = document.querySelector<HTMLElement>(`.tab-btn[data-tab="${t}"]`)!;
const active = t === name;
panel.style.display = active ? '' : 'none';
btn.classList.toggle('text-white', active);
btn.classList.toggle('border-b-2', active);
btn.classList.toggle('border-[--accent]',active);
btn.classList.toggle('-mb-px', active);
btn.classList.toggle('text-zinc-400', !active);
});
if (history.replaceState) history.replaceState(null, '', '#' + name);
}
const initial = (location.hash.slice(1) as TabName);
showTab(TABS.includes(initial) ? initial : 'about');
document.querySelectorAll<HTMLElement>('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => showTab(btn.dataset.tab as TabName));
});
// ── Community tree (About tab) ────────────────────────────────────────────────
(async () => {
try {
const me = await fetch('/api/me', { credentials: 'include' });
if (!me.ok) return;
} catch { return; }
let data: any;
try {
const r = await fetch('/api/stats');
if (!r.ok) return;
data = await r.json();
} catch { return; }
if (!data.user_count) return;
const section = document.getElementById('stats-section')!;
const summary = document.getElementById('stats-summary')!;
const treeEl = document.getElementById('stats-tree')!;
const n = data.user_count;
summary.textContent = `${n} ${n === 1 ? 'member' : 'members'}`;
section.style.display = '';
const byHandle: Record<string, any> = {};
for (const m of data.members) byHandle[m.handle] = m;
const children: Record<string, string[]> = {};
const roots: string[] = [];
for (const m of data.members) {
if (m.invited_by && byHandle[m.invited_by]) {
(children[m.invited_by] ??= []).push(m.handle);
} else {
roots.push(m.handle);
}
}
function formatDays(days: number) {
if (days < 1) return '< 1 day';
if (days < 30) return `${days} ${days === 1 ? 'day' : 'days'}`;
const mo = Math.floor(days / 30);
return mo === 1 ? '1 mo' : `${mo} mo`;
}
function renderNode(handle: string, depth: number) {
const m = byHandle[handle];
const row = document.createElement('div');
row.className = 'flex items-baseline gap-2 py-1.5 border-b border-zinc-800/50';
row.style.paddingLeft = `${depth * 20}px`;
if (depth > 0) {
const c = document.createElement('span');
c.className = 'text-zinc-700 shrink-0';
c.textContent = '└';
row.appendChild(c);
}
const name = document.createElement('span');
name.className = 'text-white font-medium';
name.textContent = m.display_name || `@${handle}`;
row.appendChild(name);
const hEl = document.createElement('span');
hEl.className = 'text-zinc-600 text-xs';
hEl.textContent = `@${handle}`;
row.appendChild(hEl);
const spacer = document.createElement('span');
spacer.className = 'flex-1';
row.appendChild(spacer);
const meta = document.createElement('span');
meta.className = 'text-zinc-600 text-xs text-right shrink-0';
const sub = m.invited_by ? `invited by @${m.invited_by}` : 'founder';
meta.innerHTML = `${formatDays(m.member_for_days)}<br><span class="text-zinc-700">${sub}</span>`;
row.appendChild(meta);
treeEl.appendChild(row);
for (const child of (children[handle] ?? [])) renderNode(child, depth + 1);
}
for (const root of roots) renderNode(root, 0);
})();
// ── Budget ────────────────────────────────────────────────────────────────────
let budgetData: { monthly_target_eur: number | null; entries: any[] } | null = null;
let isAdmin = false;
let addEntryType: 'donation' | 'expense' = 'donation';
async function loadBudget() {
try {
const [br, mr] = await Promise.all([
fetch('/api/budget'),
fetch('/api/me', { credentials: 'include' }),
]);
budgetData = br.ok ? await br.json() : { monthly_target_eur: null, entries: [] };
if (mr.ok) {
const me = await mr.json();
isAdmin = !!me.is_admin;
}
} catch {
budgetData = { monthly_target_eur: null, entries: [] };
}
renderBudget();
}
function fmtEur(n: number) {
return `€${n.toFixed(2).replace('.00', '')}`;
}
function monthLabel(ym: string) {
const [y, m] = ym.split('-');
return new Date(parseInt(y), parseInt(m) - 1, 1)
.toLocaleString('default', { month: 'long', year: 'numeric' });
}
function renderBudget() {
const listEl = document.getElementById('budget-list')!;
const adminControls = document.getElementById('budget-admin-controls')!;
const goalInput = document.getElementById('budget-goal-input') as HTMLInputElement;
if (isAdmin) {
adminControls.classList.remove('hidden');
if (budgetData?.monthly_target_eur != null) {
goalInput.value = String(budgetData.monthly_target_eur);
}
}
const entries = budgetData?.entries ?? [];
if (entries.length === 0) {
listEl.innerHTML = `<p class="text-zinc-500 text-sm text-center py-6">${isAdmin ? 'No entries yet. Add the first one above.' : 'No budget entries yet.'}</p>`;
return;
}
// Group by month, sorted descending
const byMonth: Record<string, any[]> = {};
for (const e of entries) {
(byMonth[e.month] ??= []).push(e);
}
const months = Object.keys(byMonth).sort().reverse();
let html = '';
for (const ym of months) {
const items = byMonth[ym];
const donated = items.filter(e => e.type === 'donation').reduce((s, e) => s + e.amount_eur, 0);
const spent = items.filter(e => e.type === 'expense').reduce((s, e) => s + e.amount_eur, 0);
const balance = donated - spent;
const target = budgetData?.monthly_target_eur;
const pct = target ? Math.min(100, Math.round(donated / target * 100)) : null;
html += `<div class="mb-6">`;
html += `<h3 class="text-sm font-semibold text-white mb-2">${monthLabel(ym)}</h3>`;
// Progress bar
if (pct !== null) {
html += `<div class="mb-3">
<div class="flex justify-between text-xs text-zinc-500 mb-1">
<span>Donations toward monthly goal</span>
<span>${pct}% of ${fmtEur(target!)}</span>
</div>
<div class="h-1.5 rounded-full bg-zinc-800 overflow-hidden">
<div class="h-full rounded-full transition-all" style="width:${pct}%;background:var(--accent)"></div>
</div>
</div>`;
}
// Entries list
html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`;
for (const e of items) {
const icon = e.type === 'donation' ? '💚' : '🔴';
const sign = e.type === 'donation' ? '+' : '';
const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400';
const adminBtns = isAdmin
? `<button data-edit="${e.id}" class="edit-btn text-zinc-600 hover:text-zinc-300 text-xs px-1 transition-colors">✎</button>
<button data-del="${e.id}" class="del-btn text-zinc-700 hover:text-red-400 text-xs px-1 transition-colors">✕</button>`
: '';
html += `<div class="flex items-center gap-3 px-3 py-2 bg-zinc-900 text-sm" data-entry-id="${e.id}">
<span class="text-base shrink-0">${icon}</span>
<span class="flex-1 text-zinc-300 min-w-0">
${e.label}${e.note ? `<span class="text-zinc-600 text-xs ml-2">${e.note}</span>` : ''}
</span>
<span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span>
${adminBtns}
</div>`;
}
html += `</div>`;
// Monthly totals
html += `<div class="flex gap-4 mt-2 text-xs text-zinc-500 px-1">
<span>Donated: <span class="text-green-400">${fmtEur(donated)}</span></span>
<span>Spent: <span class="text-red-400">${fmtEur(spent)}</span></span>
<span>Balance: <span class="${balance >= 0 ? 'text-zinc-300' : 'text-red-400'}">${balance >= 0 ? '+' : ''}${fmtEur(Math.abs(balance))}</span></span>
</div>`;
html += `</div>`;
}
listEl.innerHTML = html;
// Attach edit/delete handlers
listEl.querySelectorAll<HTMLElement>('.del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.del!;
if (!confirm('Delete this entry?')) return;
const r = await fetch(`/api/budget/entries/${id}`, { method: 'DELETE', credentials: 'include' });
if (r.ok) {
budgetData!.entries = budgetData!.entries.filter(e => e.id !== id);
renderBudget();
}
});
});
listEl.querySelectorAll<HTMLElement>('.edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.edit!;
const entry = budgetData!.entries.find(e => e.id === id);
if (!entry) return;
const row = listEl.querySelector<HTMLElement>(`[data-entry-id="${id}"]`)!;
row.innerHTML = `
<form class="inline-edit-form flex flex-wrap gap-2 items-center w-full py-1" data-id="${id}">
<select name="type" class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none">
<option value="donation" ${entry.type==='donation'?'selected':''}>💚 Donation</option>
<option value="expense" ${entry.type==='expense' ?'selected':''}>🔴 Expense</option>
</select>
<input name="label" value="${entry.label}" placeholder="Label"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<input name="amount_eur" type="number" value="${entry.amount_eur}" step="0.01" min="0"
class="w-20 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<input name="month" type="month" value="${entry.month}"
class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none [color-scheme:dark]" />
<input name="note" value="${entry.note}" placeholder="Note"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<button type="submit" class="px-3 py-1 rounded bg-[--accent] text-white text-xs hover:opacity-90">Save</button>
<button type="button" class="cancel-edit px-2 py-1 rounded bg-zinc-800 text-zinc-400 text-xs hover:bg-zinc-700">✕</button>
</form>`;
row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => {
ev.preventDefault();
const fd = new FormData(ev.target as HTMLFormElement);
const body = {
type: fd.get('type'),
label: fd.get('label'),
amount_eur: parseFloat(fd.get('amount_eur') as string),
month: fd.get('month'),
note: fd.get('note'),
};
const r = await fetch(`/api/budget/entries/${id}`, {
method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) {
const updated = await r.json();
const idx = budgetData!.entries.findIndex(e => e.id === id);
if (idx !== -1) budgetData!.entries[idx] = updated;
renderBudget();
}
});
row.querySelector('.cancel-edit')!.addEventListener('click', renderBudget);
});
});
}
// Load budget when donate tab is first opened
let budgetLoaded = false;
document.querySelector<HTMLElement>('.tab-btn[data-tab="donate"]')!.addEventListener('click', () => {
if (!budgetLoaded) { budgetLoaded = true; loadBudget(); }
});
// Also load immediately if donate is the initial tab
if (location.hash.slice(1) === 'donate') { budgetLoaded = true; loadBudget(); }
// Goal form
const goalForm = document.getElementById('budget-goal-form') as HTMLFormElement;
const goalBtn = document.getElementById('budget-set-goal-btn')!;
const goalCancel = document.getElementById('budget-goal-cancel')!;
const goalErr = document.getElementById('budget-goal-err')!;
goalBtn.addEventListener('click', () => goalForm.classList.toggle('hidden'));
goalCancel.addEventListener('click', () => goalForm.classList.add('hidden'));
goalForm.addEventListener('submit', async e => {
e.preventDefault();
goalErr.classList.add('hidden');
const v = parseFloat((document.getElementById('budget-goal-input') as HTMLInputElement).value);
const r = await fetch('/api/budget/settings', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monthly_target_eur: isNaN(v) ? null : v }),
});
if (r.ok) {
if (budgetData) budgetData.monthly_target_eur = isNaN(v) ? null : v;
goalForm.classList.add('hidden');
renderBudget();
} else {
goalErr.textContent = 'Failed to save goal.';
goalErr.classList.remove('hidden');
}
});
// Add-entry form
const addForm = document.getElementById('budget-add-form') as HTMLFormElement;
const addBtn = document.getElementById('budget-add-btn')!;
const addCancel = document.getElementById('budget-add-cancel')!;
const addErr = document.getElementById('budget-add-err')!;
// Default month to current YYYY-MM
const now = new Date();
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`;
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
// Type toggle in add form
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(btn => {
btn.addEventListener('click', () => {
addEntryType = btn.dataset.type as 'donation' | 'expense';
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(b => {
const active = b.dataset.type === addEntryType;
b.classList.toggle('border-[--accent]', active);
b.classList.toggle('text-white', active);
b.classList.toggle('bg-zinc-800', active);
b.classList.toggle('border-zinc-700', !active);
b.classList.toggle('text-zinc-400', !active);
});
});
});
// Activate donation type by default
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
addBtn.addEventListener('click', () => { addForm.classList.toggle('hidden'); addErr.classList.add('hidden'); });
addCancel.addEventListener('click', () => addForm.classList.add('hidden'));
addForm.addEventListener('submit', async e => {
e.preventDefault();
addErr.classList.add('hidden');
const label = (document.getElementById('entry-label') as HTMLInputElement).value.trim();
const amount = parseFloat((document.getElementById('entry-amount') as HTMLInputElement).value);
const month = (document.getElementById('entry-month') as HTMLInputElement).value;
const note = (document.getElementById('entry-note') as HTMLInputElement).value.trim();
if (!label) { addErr.textContent = 'Label is required.'; addErr.classList.remove('hidden'); return; }
if (isNaN(amount)) { addErr.textContent = 'Amount is required.'; addErr.classList.remove('hidden'); return; }
const r = await fetch('/api/budget/entries', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: addEntryType, label, amount_eur: amount, month, note }),
});
if (r.ok) {
const entry = await r.json();
budgetData!.entries.push(entry);
addForm.classList.add('hidden');
addForm.reset();
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
renderBudget();
} else {
const d = await r.json().catch(() => ({}));
addErr.textContent = d.detail ?? 'Failed to add entry.';
addErr.classList.remove('hidden');
}
});
// ── Feedback ──────────────────────────────────────────────────────────────────
const MAX_IMAGES = 3;
const MAX_BYTES = 2 * 1024 * 1024;
const fbForm = document.getElementById('feedback-form') as HTMLFormElement;
const fbDrop = document.getElementById('fb-drop')!;
const fbInput = document.getElementById('fb-input') as HTMLInputElement;
const fbPreviews = document.getElementById('fb-previews')!;
const fbErr = document.getElementById('fb-error')!;
const fbSuccess = document.getElementById('fb-success')!;
let fbFiles: File[] = [];
function fbShowErr(msg: string) { fbErr.textContent = msg; fbErr.classList.remove('hidden'); }
function fbClearErr() { fbErr.classList.add('hidden'); }
function fbRenderPreviews() {
fbPreviews.innerHTML = '';
fbFiles.forEach((f, i) => {
const url = URL.createObjectURL(f);
const wrap = document.createElement('div');
wrap.className = 'relative';
wrap.innerHTML = `<img src="${url}" class="w-20 h-20 object-cover rounded-lg border border-zinc-700" />
<button type="button" data-i="${i}" class="rm-btn absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-zinc-800 border border-zinc-600 text-zinc-300 hover:text-white text-xs flex items-center justify-center">×</button>`;
fbPreviews.appendChild(wrap);
});
fbPreviews.querySelectorAll<HTMLElement>('.rm-btn').forEach(b => {
b.addEventListener('click', () => { fbFiles.splice(parseInt(b.dataset.i!), 1); fbRenderPreviews(); fbClearErr(); });
});
}
function fbAddFiles(files: FileList | File[]) {
fbClearErr();
for (const f of Array.from(files)) {
if (fbFiles.length >= MAX_IMAGES) { fbShowErr(`Max ${MAX_IMAGES} images.`); break; }
if (f.size > MAX_BYTES) { fbShowErr(`"${f.name}" exceeds 2 MB.`); continue; }
fbFiles.push(f);
}
fbRenderPreviews();
fbInput.value = '';
}
fbDrop.addEventListener('click', () => fbInput.click());
fbDrop.addEventListener('dragover', e => { e.preventDefault(); fbDrop.style.borderColor = 'var(--accent)'; });
fbDrop.addEventListener('dragleave', () => { fbDrop.style.borderColor = ''; });
fbDrop.addEventListener('drop', e => { e.preventDefault(); fbDrop.style.borderColor = ''; if (e.dataTransfer?.files.length) fbAddFiles(e.dataTransfer.files); });
fbInput.addEventListener('change', () => { if (fbInput.files?.length) fbAddFiles(fbInput.files); });
fbForm.addEventListener('submit', async e => {
e.preventDefault();
fbClearErr();
const text = (document.getElementById('fb-text') as HTMLTextAreaElement).value.trim();
if (!text && fbFiles.length === 0) { fbShowErr('Please write something or attach an image.'); return; }
const fd = new FormData();
fd.append('text', text);
for (const f of fbFiles) fd.append('images', f);
const btn = fbForm.querySelector('button[type=submit]') as HTMLButtonElement;
btn.disabled = true; btn.textContent = 'Sending…';
try {
const r = await fetch('/api/feedback', { method: 'POST', credentials: 'include', body: fd });
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail ?? `Error ${r.status}`); }
fbForm.classList.add('hidden');
fbSuccess.classList.remove('hidden');
} catch (err: any) {
fbShowErr(err.message);
btn.disabled = false; btn.textContent = 'Send feedback';
}
});
// Auth check for feedback
(async () => {
try {
const r = await fetch('/api/me', { credentials: 'include' });
if (r.status === 401) {
document.getElementById('tab-feedback')!.innerHTML =
`<p class="text-zinc-500 text-sm py-6">You need to <a href="/login/?next=/support/#feedback" class="text-[--accent] hover:underline">log in</a> to send feedback.</p>`;
}
} catch (_) {}
})();
</script>