Add invite system: /invites/ management page, /join/ registration page, nav link

This commit is contained in:
Davide Scaini
2026-05-08 09:43:45 +02:00
parent dfce744001
commit aed1da2cc9
3 changed files with 253 additions and 0 deletions
+3
View File
@@ -142,6 +142,7 @@ const { title = 'BincioWiki', description = 'La memoria collettiva del gruppo Bi
<div class="ml-auto shrink-0 flex items-center gap-2">
<span id="nav-handle" class="text-xs text-zinc-500" style="display:none"></span>
<a id="nav-wikilog" href="/log/" class="text-xs text-zinc-500 hover:text-white transition-colors px-1" style="display:none">WikiLog</a>
<a id="nav-invites" href="/invites/" class="text-xs text-zinc-500 hover:text-white transition-colors px-1" style="display:none">Inviti</a>
<button id="nav-logout" class="text-xs text-zinc-500 hover:text-white transition-colors px-1" style="display:none">Log out</button>
<button
id="theme-toggle"
@@ -178,6 +179,8 @@ const { title = 'BincioWiki', description = 'La memoria collettiva del gruppo Bi
const logoutEl = document.getElementById('nav-logout');
if (handleEl) { handleEl.textContent = '@' + user.handle; handleEl.style.display = ''; }
if (wikilogEl) wikilogEl.style.display = '';
const invitesEl = document.getElementById('nav-invites');
if (invitesEl) invitesEl.style.display = '';
if (logoutEl) logoutEl.style.display = '';
}
})
+113
View File
@@ -0,0 +1,113 @@
---
import Base from '../../layouts/Base.astro';
import { SITE_TITLE } from '../../consts';
---
<Base title={`Inviti — ${SITE_TITLE}`} description="Invita qualcuno al wiki">
<div class="max-w-2xl mx-auto">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Inviti</h1>
<button
id="btn-new"
class="text-sm px-4 py-2 rounded border transition-colors"
style="color: var(--accent); border-color: var(--accent)"
>+ Genera invito</button>
</div>
<div id="error-msg" class="hidden mb-4 text-sm px-3 py-2 rounded bg-red-900/40 text-red-300 border border-red-800"></div>
<div id="invite-list" class="space-y-2">
<p class="text-sm" style="color: var(--text-4)">Caricamento…</p>
</div>
</div>
</Base>
<script>
const listEl = document.getElementById('invite-list')!;
const errorEl = document.getElementById('error-msg')!;
const btnNew = document.getElementById('btn-new')!;
function showError(msg: string) {
errorEl.textContent = msg;
errorEl.classList.remove('hidden');
}
function formatDate(ts: number): string {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleDateString('it-IT', { day: 'numeric', month: 'short', year: 'numeric' });
}
function inviteUrl(code: string): string {
return `${window.location.origin}/join/?code=${code}`;
}
async function loadInvites() {
try {
const r = await fetch('/api/invites', { credentials: 'include' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const { invites } = await r.json();
if (!invites.length) {
listEl.innerHTML = '<p class="text-sm" style="color:var(--text-4)">Nessun invito ancora. Generane uno!</p>';
return;
}
listEl.innerHTML = invites.map((inv: any) => `
<div class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm" style="background:var(--bg-card)">
${inv.used_by
? `<span class="flex-1 font-mono text-xs truncate" style="color:var(--text-5)">${inviteUrl(inv.code)}</span>
<span class="shrink-0 text-xs" style="color:var(--text-5)">usato da <span style="color:var(--accent)">@${inv.used_by}</span></span>`
: `<span class="flex-1 font-mono text-xs truncate" style="color:var(--text-2)">${inviteUrl(inv.code)}</span>
<button
class="btn-copy shrink-0 text-xs px-2 py-0.5 rounded border transition-colors"
style="color:var(--accent);border-color:var(--accent)"
data-code="${inv.code}"
>Copia</button>
<button
class="btn-revoke shrink-0 text-xs text-zinc-500 hover:text-red-400 transition-colors px-2 py-0.5 rounded border border-zinc-700 hover:border-red-800"
data-code="${inv.code}"
>Revoca</button>`
}
<span class="shrink-0 text-xs" style="color:var(--text-5)">${formatDate(inv.created_at)}</span>
</div>
`).join('');
listEl.querySelectorAll('.btn-copy').forEach(btn => {
btn.addEventListener('click', () => {
const url = inviteUrl((btn as HTMLElement).dataset.code!);
navigator.clipboard.writeText(url).then(() => {
btn.textContent = 'Copiato!';
setTimeout(() => { btn.textContent = 'Copia'; }, 2000);
});
});
});
listEl.querySelectorAll('.btn-revoke').forEach(btn => {
btn.addEventListener('click', async () => {
const code = (btn as HTMLElement).dataset.code!;
if (!confirm('Revocare questo invito?')) return;
const r = await fetch(`/api/invites/${code}`, { method: 'DELETE', credentials: 'include' });
if (r.ok) loadInvites();
else { const d = await r.json().catch(() => ({})); showError(d.detail || 'Errore'); }
});
});
} catch (e) {
listEl.innerHTML = '<p class="text-sm" style="color:#f87171">Errore nel caricamento.</p>';
}
}
btnNew.addEventListener('click', async () => {
errorEl.classList.add('hidden');
btnNew.setAttribute('disabled', '');
try {
const r = await fetch('/api/invites', { method: 'POST', credentials: 'include' });
if (!r.ok) { const d = await r.json().catch(() => ({})); showError(d.detail || 'Errore'); return; }
await loadInvites();
} finally {
btnNew.removeAttribute('disabled');
}
});
loadInvites();
</script>
+137
View File
@@ -0,0 +1,137 @@
---
import Base from '../../layouts/Base.astro';
---
<Base title="Registrati — BincioWiki" public={true}>
<div class="max-w-sm mx-auto mt-16">
<h1 class="text-2xl font-bold mb-2 text-center" style="color: var(--text-primary)">
Bincio<span style="color: var(--accent)">Wiki</span>
</h1>
<p class="text-sm text-center mb-8" style="color: var(--text-4)">Crea il tuo account</p>
<div id="invalid-msg" class="hidden text-sm px-3 py-2 rounded bg-red-900/40 text-red-300 border border-red-800 mb-4">
Codice invito non valido o già utilizzato.
</div>
<form id="register-form" class="flex flex-col gap-4">
<div id="error-msg" class="hidden text-sm px-3 py-2 rounded bg-red-900/40 text-red-300 border border-red-800"></div>
<div class="flex flex-col gap-1">
<label class="text-sm" style="color: var(--text-4)">Handle</label>
<input
id="handle"
type="text"
autocomplete="username"
autocapitalize="none"
placeholder="es. mario"
required
class="px-3 py-2 rounded border text-sm bg-zinc-900 border-zinc-700 focus:outline-none focus:border-zinc-500"
style="color: var(--text-primary)"
/>
<span class="text-xs" style="color: var(--text-5)">2-20 caratteri: lettere minuscole, numeri, _ o -</span>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm" style="color: var(--text-4)">Nome visualizzato <span style="color:var(--text-5)">(opzionale)</span></label>
<input
id="display-name"
type="text"
autocomplete="name"
placeholder="es. Mario Rossi"
class="px-3 py-2 rounded border text-sm bg-zinc-900 border-zinc-700 focus:outline-none focus:border-zinc-500"
style="color: var(--text-primary)"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm" style="color: var(--text-4)">Password</label>
<input
id="password"
type="password"
autocomplete="new-password"
required
class="px-3 py-2 rounded border text-sm bg-zinc-900 border-zinc-700 focus:outline-none focus:border-zinc-500"
style="color: var(--text-primary)"
/>
<span class="text-xs" style="color: var(--text-5)">Minimo 8 caratteri</span>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm" style="color: var(--text-4)">Conferma password</label>
<input
id="confirm-password"
type="password"
autocomplete="new-password"
required
class="px-3 py-2 rounded border text-sm bg-zinc-900 border-zinc-700 focus:outline-none focus:border-zinc-500"
style="color: var(--text-primary)"
/>
</div>
<button
type="submit"
id="submit-btn"
class="mt-2 px-4 py-2 rounded text-sm font-medium transition-colors"
style="background: var(--accent); color: #000"
>
Registrati
</button>
</form>
</div>
</Base>
<script>
const params = new URLSearchParams(window.location.search);
const code = params.get('code') || '';
const form = document.getElementById('register-form') as HTMLFormElement;
const errorEl = document.getElementById('error-msg') as HTMLDivElement;
const invalidEl = document.getElementById('invalid-msg') as HTMLDivElement;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement;
if (!code) {
form.classList.add('hidden');
invalidEl.classList.remove('hidden');
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.classList.add('hidden');
const handle = (document.getElementById('handle') as HTMLInputElement).value.trim();
const displayName = (document.getElementById('display-name') as HTMLInputElement).value.trim();
const password = (document.getElementById('password') as HTMLInputElement).value;
const confirm = (document.getElementById('confirm-password') as HTMLInputElement).value;
if (password !== confirm) {
errorEl.textContent = 'Le password non coincidono';
errorEl.classList.remove('hidden');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Registrazione…';
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, handle, display_name: displayName, password }),
});
if (res.ok) {
window.location.replace('/');
} else {
const data = await res.json().catch(() => ({}));
errorEl.textContent = data.detail || 'Errore durante la registrazione';
errorEl.classList.remove('hidden');
}
} catch {
errorEl.textContent = 'Errore di rete. Riprova.';
errorEl.classList.remove('hidden');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Registrati';
}
});
</script>