Add invite system: /invites/ management page, /join/ registration page, nav link
This commit is contained in:
@@ -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 = '';
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user