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">
|
<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>
|
<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-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="nav-logout" class="text-xs text-zinc-500 hover:text-white transition-colors px-1" style="display:none">Log out</button>
|
||||||
<button
|
<button
|
||||||
id="theme-toggle"
|
id="theme-toggle"
|
||||||
@@ -178,6 +179,8 @@ const { title = 'BincioWiki', description = 'La memoria collettiva del gruppo Bi
|
|||||||
const logoutEl = document.getElementById('nav-logout');
|
const logoutEl = document.getElementById('nav-logout');
|
||||||
if (handleEl) { handleEl.textContent = '@' + user.handle; handleEl.style.display = ''; }
|
if (handleEl) { handleEl.textContent = '@' + user.handle; handleEl.style.display = ''; }
|
||||||
if (wikilogEl) wikilogEl.style.display = '';
|
if (wikilogEl) wikilogEl.style.display = '';
|
||||||
|
const invitesEl = document.getElementById('nav-invites');
|
||||||
|
if (invitesEl) invitesEl.style.display = '';
|
||||||
if (logoutEl) logoutEl.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