fix: support page — donate default tab, split community tab, require login
This commit is contained in:
+141
-226
@@ -2,34 +2,109 @@
|
|||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||||
---
|
---
|
||||||
<Base title="Support — BincioActivity" public={true}>
|
<Base title="Support — BincioActivity">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<h1 class="text-2xl font-bold text-white mb-1">Support</h1>
|
<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>
|
<p class="text-sm text-zinc-500 mb-5">Open-source, self-hosted activity tracking</p>
|
||||||
|
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<div class="flex gap-1 border-b border-zinc-800 mb-6">
|
<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="donate" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors">Donate</button>
|
||||||
|
<button data-tab="about" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors">About</button>
|
||||||
|
<button data-tab="community" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors">Community</button>
|
||||||
<button data-tab="feedback" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors">Feedback</button>
|
<button data-tab="feedback" class="tab-btn px-4 py-2 text-sm font-medium rounded-t transition-colors">Feedback</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DONATE ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="tab-donate" class="tab-panel">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div id="budget-list">
|
||||||
|
<p class="text-zinc-500 text-sm text-center py-6">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── ABOUT ──────────────────────────────────────────────────────── -->
|
<!-- ── ABOUT ──────────────────────────────────────────────────────── -->
|
||||||
<div id="tab-about" class="tab-panel">
|
<div id="tab-about" class="tab-panel" style="display:none">
|
||||||
<div class="flex justify-end gap-3 text-xs text-zinc-500 mb-5">
|
<div class="flex justify-end gap-3 text-xs text-zinc-500 mb-5">
|
||||||
<span class="text-zinc-300 font-medium">EN</span>
|
<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/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/es/`} class="hover:text-white transition-colors">ES</a>
|
||||||
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
|
<a href={`${baseUrl}about/ca/`} class="hover:text-white transition-colors">CA</a>
|
||||||
</div>
|
</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">
|
<div class="space-y-8 text-sm text-zinc-400 leading-relaxed">
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-base font-semibold text-white mb-2">What is this?</h2>
|
<h2 class="text-base font-semibold text-white mb-2">What is this?</h2>
|
||||||
@@ -54,8 +129,7 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
Once you have an account, you can generate up to <strong class="text-zinc-300">3 invite links</strong> to
|
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
|
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>
|
<a href="/invites/" class="text-blue-400 hover:text-blue-300 transition-colors">invites page</a>.
|
||||||
(requires login).
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -118,88 +192,11 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── DONATE ─────────────────────────────────────────────────────── -->
|
<!-- ── COMMUNITY ──────────────────────────────────────────────────── -->
|
||||||
<div id="tab-donate" class="tab-panel" style="display:none">
|
<div id="tab-community" class="tab-panel" style="display:none">
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<p id="community-summary" class="text-zinc-500 text-xs mb-4"></p>
|
||||||
<a href="https://ko-fi.com/brutsalvadi" target="_blank" rel="noopener noreferrer"
|
<div id="community-tree" class="text-sm"></div>
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
<p id="community-empty" class="text-zinc-500 text-sm text-center py-6" style="display:none">Community stats not available.</p>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<!-- ── FEEDBACK ───────────────────────────────────────────────────── -->
|
<!-- ── FEEDBACK ───────────────────────────────────────────────────── -->
|
||||||
@@ -215,7 +212,7 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
<p class="text-xs text-zinc-500 mb-2">Attach up to 3 screenshots (max 2 MB each)</p>
|
<p class="text-xs text-zinc-500 mb-2">Attach up to 3 screenshots (max 2 MB each)</p>
|
||||||
<div id="fb-drop"
|
<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">
|
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>
|
<span>Drop images or click to browse</span>
|
||||||
<input id="fb-input" type="file" accept="image/*" multiple class="hidden" />
|
<input id="fb-input" type="file" accept="image/*" multiple class="hidden" />
|
||||||
</div>
|
</div>
|
||||||
<div id="fb-previews" class="flex gap-2 flex-wrap mt-2"></div>
|
<div id="fb-previews" class="flex gap-2 flex-wrap mt-2"></div>
|
||||||
@@ -239,7 +236,7 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
const TABS = ['about', 'donate', 'feedback'] as const;
|
const TABS = ['donate', 'about', 'community', 'feedback'] as const;
|
||||||
type TabName = typeof TABS[number];
|
type TabName = typeof TABS[number];
|
||||||
|
|
||||||
function showTab(name: TabName) {
|
function showTab(name: TabName) {
|
||||||
@@ -257,32 +254,29 @@ function showTab(name: TabName) {
|
|||||||
if (history.replaceState) history.replaceState(null, '', '#' + name);
|
if (history.replaceState) history.replaceState(null, '', '#' + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const initial = (location.hash.slice(1) as TabName);
|
const hash = location.hash.slice(1) as TabName;
|
||||||
showTab(TABS.includes(initial) ? initial : 'about');
|
showTab(TABS.includes(hash) ? hash : 'donate');
|
||||||
document.querySelectorAll<HTMLElement>('.tab-btn').forEach(btn => {
|
document.querySelectorAll<HTMLElement>('.tab-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => showTab(btn.dataset.tab as TabName));
|
btn.addEventListener('click', () => showTab(btn.dataset.tab as TabName));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Community tree (About tab) ────────────────────────────────────────────────
|
// ── Community tree ────────────────────────────────────────────────────────────
|
||||||
(async () => {
|
let communityLoaded = false;
|
||||||
try {
|
|
||||||
const me = await fetch('/api/me', { credentials: 'include' });
|
async function loadCommunity() {
|
||||||
if (!me.ok) return;
|
const summaryEl = document.getElementById('community-summary')!;
|
||||||
} catch { return; }
|
const treeEl = document.getElementById('community-tree')!;
|
||||||
|
const emptyEl = document.getElementById('community-empty')!;
|
||||||
let data: any;
|
let data: any;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/stats');
|
const r = await fetch('/api/stats');
|
||||||
if (!r.ok) return;
|
if (!r.ok) { emptyEl.style.display = ''; return; }
|
||||||
data = await r.json();
|
data = await r.json();
|
||||||
} catch { return; }
|
} catch { emptyEl.style.display = ''; return; }
|
||||||
if (!data.user_count) return;
|
if (!data.user_count) { emptyEl.style.display = ''; return; }
|
||||||
|
|
||||||
const section = document.getElementById('stats-section')!;
|
|
||||||
const summary = document.getElementById('stats-summary')!;
|
|
||||||
const treeEl = document.getElementById('stats-tree')!;
|
|
||||||
const n = data.user_count;
|
const n = data.user_count;
|
||||||
summary.textContent = `${n} ${n === 1 ? 'member' : 'members'}`;
|
summaryEl.textContent = `${n} ${n === 1 ? 'member' : 'members'}`;
|
||||||
section.style.display = '';
|
|
||||||
|
|
||||||
const byHandle: Record<string, any> = {};
|
const byHandle: Record<string, any> = {};
|
||||||
for (const m of data.members) byHandle[m.handle] = m;
|
for (const m of data.members) byHandle[m.handle] = m;
|
||||||
@@ -334,7 +328,12 @@ document.querySelectorAll<HTMLElement>('.tab-btn').forEach(btn => {
|
|||||||
for (const child of (children[handle] ?? [])) renderNode(child, depth + 1);
|
for (const child of (children[handle] ?? [])) renderNode(child, depth + 1);
|
||||||
}
|
}
|
||||||
for (const root of roots) renderNode(root, 0);
|
for (const root of roots) renderNode(root, 0);
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
document.querySelector<HTMLElement>('.tab-btn[data-tab="community"]')!.addEventListener('click', () => {
|
||||||
|
if (!communityLoaded) { communityLoaded = true; loadCommunity(); }
|
||||||
|
});
|
||||||
|
if (location.hash.slice(1) === 'community') { communityLoaded = true; loadCommunity(); }
|
||||||
|
|
||||||
// ── Budget ────────────────────────────────────────────────────────────────────
|
// ── Budget ────────────────────────────────────────────────────────────────────
|
||||||
let budgetData: { monthly_target_eur: number | null; entries: any[] } | null = null;
|
let budgetData: { monthly_target_eur: number | null; entries: any[] } | null = null;
|
||||||
@@ -348,10 +347,7 @@ async function loadBudget() {
|
|||||||
fetch('/api/me', { credentials: 'include' }),
|
fetch('/api/me', { credentials: 'include' }),
|
||||||
]);
|
]);
|
||||||
budgetData = br.ok ? await br.json() : { monthly_target_eur: null, entries: [] };
|
budgetData = br.ok ? await br.json() : { monthly_target_eur: null, entries: [] };
|
||||||
if (mr.ok) {
|
if (mr.ok) { const me = await mr.json(); isAdmin = !!me.is_admin; }
|
||||||
const me = await mr.json();
|
|
||||||
isAdmin = !!me.is_admin;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
budgetData = { monthly_target_eur: null, entries: [] };
|
budgetData = { monthly_target_eur: null, entries: [] };
|
||||||
}
|
}
|
||||||
@@ -370,14 +366,12 @@ function monthLabel(ym: string) {
|
|||||||
|
|
||||||
function renderBudget() {
|
function renderBudget() {
|
||||||
const listEl = document.getElementById('budget-list')!;
|
const listEl = document.getElementById('budget-list')!;
|
||||||
const adminControls = document.getElementById('budget-admin-controls')!;
|
const adminCtrls = document.getElementById('budget-admin-controls')!;
|
||||||
const goalInput = document.getElementById('budget-goal-input') as HTMLInputElement;
|
const goalInput = document.getElementById('budget-goal-input') as HTMLInputElement;
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
adminControls.classList.remove('hidden');
|
adminCtrls.classList.remove('hidden');
|
||||||
if (budgetData?.monthly_target_eur != null) {
|
if (budgetData?.monthly_target_eur != null) goalInput.value = String(budgetData.monthly_target_eur);
|
||||||
goalInput.value = String(budgetData.monthly_target_eur);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = budgetData?.entries ?? [];
|
const entries = budgetData?.entries ?? [];
|
||||||
@@ -386,11 +380,8 @@ function renderBudget() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by month, sorted descending
|
|
||||||
const byMonth: Record<string, any[]> = {};
|
const byMonth: Record<string, any[]> = {};
|
||||||
for (const e of entries) {
|
for (const e of entries) (byMonth[e.month] ??= []).push(e);
|
||||||
(byMonth[e.month] ??= []).push(e);
|
|
||||||
}
|
|
||||||
const months = Object.keys(byMonth).sort().reverse();
|
const months = Object.keys(byMonth).sort().reverse();
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
@@ -405,7 +396,6 @@ function renderBudget() {
|
|||||||
html += `<div class="mb-6">`;
|
html += `<div class="mb-6">`;
|
||||||
html += `<h3 class="text-sm font-semibold text-white mb-2">${monthLabel(ym)}</h3>`;
|
html += `<h3 class="text-sm font-semibold text-white mb-2">${monthLabel(ym)}</h3>`;
|
||||||
|
|
||||||
// Progress bar
|
|
||||||
if (pct !== null) {
|
if (pct !== null) {
|
||||||
html += `<div class="mb-3">
|
html += `<div class="mb-3">
|
||||||
<div class="flex justify-between text-xs text-zinc-500 mb-1">
|
<div class="flex justify-between text-xs text-zinc-500 mb-1">
|
||||||
@@ -418,7 +408,6 @@ function renderBudget() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entries list
|
|
||||||
html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`;
|
html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`;
|
||||||
for (const e of items) {
|
for (const e of items) {
|
||||||
const icon = e.type === 'donation' ? '💚' : '🔴';
|
const icon = e.type === 'donation' ? '💚' : '🔴';
|
||||||
@@ -438,8 +427,6 @@ function renderBudget() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|
||||||
// Monthly totals
|
|
||||||
html += `<div class="flex gap-4 mt-2 text-xs text-zinc-500 px-1">
|
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>Donated: <span class="text-green-400">${fmtEur(donated)}</span></span>
|
||||||
<span>Spent: <span class="text-red-400">${fmtEur(spent)}</span></span>
|
<span>Spent: <span class="text-red-400">${fmtEur(spent)}</span></span>
|
||||||
@@ -449,16 +436,11 @@ function renderBudget() {
|
|||||||
}
|
}
|
||||||
listEl.innerHTML = html;
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
// Attach edit/delete handlers
|
|
||||||
listEl.querySelectorAll<HTMLElement>('.del-btn').forEach(btn => {
|
listEl.querySelectorAll<HTMLElement>('.del-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const id = btn.dataset.del!;
|
|
||||||
if (!confirm('Delete this entry?')) return;
|
if (!confirm('Delete this entry?')) return;
|
||||||
const r = await fetch(`/api/budget/entries/${id}`, { method: 'DELETE', credentials: 'include' });
|
const r = await fetch(`/api/budget/entries/${btn.dataset.del}`, { method: 'DELETE', credentials: 'include' });
|
||||||
if (r.ok) {
|
if (r.ok) { budgetData!.entries = budgetData!.entries.filter(e => e.id !== btn.dataset.del); renderBudget(); }
|
||||||
budgetData!.entries = budgetData!.entries.filter(e => e.id !== id);
|
|
||||||
renderBudget();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -489,38 +471,17 @@ function renderBudget() {
|
|||||||
row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => {
|
row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const fd = new FormData(ev.target as HTMLFormElement);
|
const fd = new FormData(ev.target as HTMLFormElement);
|
||||||
const body = {
|
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') };
|
||||||
type: fd.get('type'),
|
const r = await fetch(`/api/budget/entries/${id}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
label: fd.get('label'),
|
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(); }
|
||||||
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);
|
row.querySelector('.cancel-edit')!.addEventListener('click', renderBudget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load budget when donate tab is first opened
|
// Load budget on page load (donate is the default tab)
|
||||||
let budgetLoaded = false;
|
loadBudget();
|
||||||
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
|
// Goal form
|
||||||
const goalForm = document.getElementById('budget-goal-form') as HTMLFormElement;
|
const goalForm = document.getElementById('budget-goal-form') as HTMLFormElement;
|
||||||
@@ -533,19 +494,9 @@ goalForm.addEventListener('submit', async e => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
goalErr.classList.add('hidden');
|
goalErr.classList.add('hidden');
|
||||||
const v = parseFloat((document.getElementById('budget-goal-input') as HTMLInputElement).value);
|
const v = parseFloat((document.getElementById('budget-goal-input') as HTMLInputElement).value);
|
||||||
const r = await fetch('/api/budget/settings', {
|
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 }) });
|
||||||
method: 'POST', credentials: 'include',
|
if (r.ok) { if (budgetData) budgetData.monthly_target_eur = isNaN(v) ? null : v; goalForm.classList.add('hidden'); renderBudget(); }
|
||||||
headers: { 'Content-Type': 'application/json' },
|
else { goalErr.textContent = 'Failed to save goal.'; goalErr.classList.remove('hidden'); }
|
||||||
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
|
// Add-entry form
|
||||||
@@ -554,26 +505,20 @@ const addBtn = document.getElementById('budget-add-btn')!;
|
|||||||
const addCancel = document.getElementById('budget-add-cancel')!;
|
const addCancel = document.getElementById('budget-add-cancel')!;
|
||||||
const addErr = document.getElementById('budget-add-err')!;
|
const addErr = document.getElementById('budget-add-err')!;
|
||||||
|
|
||||||
// Default month to current YYYY-MM
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`;
|
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`;
|
||||||
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
|
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
|
||||||
|
|
||||||
// Type toggle in add form
|
|
||||||
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(btn => {
|
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
addEntryType = btn.dataset.type as 'donation' | 'expense';
|
addEntryType = btn.dataset.type as 'donation' | 'expense';
|
||||||
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(b => {
|
addForm.querySelectorAll<HTMLButtonElement>('.entry-type-btn').forEach(b => {
|
||||||
const active = b.dataset.type === addEntryType;
|
const a = b.dataset.type === addEntryType;
|
||||||
b.classList.toggle('border-[--accent]', active);
|
b.classList.toggle('border-[--accent]', a); b.classList.toggle('text-white', a); b.classList.toggle('bg-zinc-800', a);
|
||||||
b.classList.toggle('text-white', active);
|
b.classList.toggle('border-zinc-700', !a); b.classList.toggle('text-zinc-400', !a);
|
||||||
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();
|
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
|
||||||
|
|
||||||
addBtn.addEventListener('click', () => { addForm.classList.toggle('hidden'); addErr.classList.add('hidden'); });
|
addBtn.addEventListener('click', () => { addForm.classList.toggle('hidden'); addErr.classList.add('hidden'); });
|
||||||
@@ -588,23 +533,16 @@ addForm.addEventListener('submit', async e => {
|
|||||||
const note = (document.getElementById('entry-note') as HTMLInputElement).value.trim();
|
const note = (document.getElementById('entry-note') as HTMLInputElement).value.trim();
|
||||||
if (!label) { addErr.textContent = 'Label is required.'; addErr.classList.remove('hidden'); return; }
|
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; }
|
if (isNaN(amount)) { addErr.textContent = 'Amount is required.'; addErr.classList.remove('hidden'); return; }
|
||||||
const r = await fetch('/api/budget/entries', {
|
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 }) });
|
||||||
method: 'POST', credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: addEntryType, label, amount_eur: amount, month, note }),
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const entry = await r.json();
|
budgetData!.entries.push(await r.json());
|
||||||
budgetData!.entries.push(entry);
|
addForm.classList.add('hidden'); addForm.reset();
|
||||||
addForm.classList.add('hidden');
|
|
||||||
addForm.reset();
|
|
||||||
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
|
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
|
||||||
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
|
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
|
||||||
renderBudget();
|
renderBudget();
|
||||||
} else {
|
} else {
|
||||||
const d = await r.json().catch(() => ({}));
|
const d = await r.json().catch(() => ({}));
|
||||||
addErr.textContent = d.detail ?? 'Failed to add entry.';
|
addErr.textContent = d.detail ?? 'Failed to add entry.'; addErr.classList.remove('hidden');
|
||||||
addErr.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -621,14 +559,12 @@ let fbFiles: File[] = [];
|
|||||||
|
|
||||||
function fbShowErr(msg: string) { fbErr.textContent = msg; fbErr.classList.remove('hidden'); }
|
function fbShowErr(msg: string) { fbErr.textContent = msg; fbErr.classList.remove('hidden'); }
|
||||||
function fbClearErr() { fbErr.classList.add('hidden'); }
|
function fbClearErr() { fbErr.classList.add('hidden'); }
|
||||||
|
|
||||||
function fbRenderPreviews() {
|
function fbRenderPreviews() {
|
||||||
fbPreviews.innerHTML = '';
|
fbPreviews.innerHTML = '';
|
||||||
fbFiles.forEach((f, i) => {
|
fbFiles.forEach((f, i) => {
|
||||||
const url = URL.createObjectURL(f);
|
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'relative';
|
wrap.className = 'relative';
|
||||||
wrap.innerHTML = `<img src="${url}" class="w-20 h-20 object-cover rounded-lg border border-zinc-700" />
|
wrap.innerHTML = `<img src="${URL.createObjectURL(f)}" 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>`;
|
<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.appendChild(wrap);
|
||||||
});
|
});
|
||||||
@@ -636,7 +572,6 @@ function fbRenderPreviews() {
|
|||||||
b.addEventListener('click', () => { fbFiles.splice(parseInt(b.dataset.i!), 1); fbRenderPreviews(); fbClearErr(); });
|
b.addEventListener('click', () => { fbFiles.splice(parseInt(b.dataset.i!), 1); fbRenderPreviews(); fbClearErr(); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fbAddFiles(files: FileList | File[]) {
|
function fbAddFiles(files: FileList | File[]) {
|
||||||
fbClearErr();
|
fbClearErr();
|
||||||
for (const f of Array.from(files)) {
|
for (const f of Array.from(files)) {
|
||||||
@@ -644,45 +579,25 @@ function fbAddFiles(files: FileList | File[]) {
|
|||||||
if (f.size > MAX_BYTES) { fbShowErr(`"${f.name}" exceeds 2 MB.`); continue; }
|
if (f.size > MAX_BYTES) { fbShowErr(`"${f.name}" exceeds 2 MB.`); continue; }
|
||||||
fbFiles.push(f);
|
fbFiles.push(f);
|
||||||
}
|
}
|
||||||
fbRenderPreviews();
|
fbRenderPreviews(); fbInput.value = '';
|
||||||
fbInput.value = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fbDrop.addEventListener('click', () => fbInput.click());
|
fbDrop.addEventListener('click', () => fbInput.click());
|
||||||
fbDrop.addEventListener('dragover', e => { e.preventDefault(); fbDrop.style.borderColor = 'var(--accent)'; });
|
fbDrop.addEventListener('dragover', e => { e.preventDefault(); fbDrop.style.borderColor = 'var(--accent)'; });
|
||||||
fbDrop.addEventListener('dragleave', () => { fbDrop.style.borderColor = ''; });
|
fbDrop.addEventListener('dragleave', () => { fbDrop.style.borderColor = ''; });
|
||||||
fbDrop.addEventListener('drop', e => { e.preventDefault(); fbDrop.style.borderColor = ''; if (e.dataTransfer?.files.length) fbAddFiles(e.dataTransfer.files); });
|
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); });
|
fbInput.addEventListener('change', () => { if (fbInput.files?.length) fbAddFiles(fbInput.files); });
|
||||||
|
|
||||||
fbForm.addEventListener('submit', async e => {
|
fbForm.addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault(); fbClearErr();
|
||||||
fbClearErr();
|
|
||||||
const text = (document.getElementById('fb-text') as HTMLTextAreaElement).value.trim();
|
const text = (document.getElementById('fb-text') as HTMLTextAreaElement).value.trim();
|
||||||
if (!text && fbFiles.length === 0) { fbShowErr('Please write something or attach an image.'); return; }
|
if (!text && fbFiles.length === 0) { fbShowErr('Please write something or attach an image.'); return; }
|
||||||
const fd = new FormData();
|
const fd = new FormData(); fd.append('text', text);
|
||||||
fd.append('text', text);
|
|
||||||
for (const f of fbFiles) fd.append('images', f);
|
for (const f of fbFiles) fd.append('images', f);
|
||||||
const btn = fbForm.querySelector('button[type=submit]') as HTMLButtonElement;
|
const btn = fbForm.querySelector('button[type=submit]') as HTMLButtonElement;
|
||||||
btn.disabled = true; btn.textContent = 'Sending…';
|
btn.disabled = true; btn.textContent = 'Sending…';
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/feedback', { method: 'POST', credentials: 'include', body: fd });
|
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}`); }
|
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail ?? `Error ${r.status}`); }
|
||||||
fbForm.classList.add('hidden');
|
fbForm.classList.add('hidden'); fbSuccess.classList.remove('hidden');
|
||||||
fbSuccess.classList.remove('hidden');
|
} catch (err: any) { fbShowErr(err.message); btn.disabled = false; btn.textContent = 'Send feedback'; }
|
||||||
} 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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user