feat(ui): replace admin dialogs with toasts

This commit is contained in:
Marco Allegretti 2026-02-15 22:29:46 +01:00
parent 11afe56d87
commit 1d3780d7fd
6 changed files with 311 additions and 20 deletions

View file

@ -0,0 +1,264 @@
<div id="likwid-toasts" class="ui-toasts" aria-live="polite" aria-atomic="true"></div>
<dialog id="likwid-dialog" class="ui-dialog">
<form method="dialog" class="ui-dialog-form">
<div class="ui-dialog-head">
<h3 id="likwid-dialog-title" class="ui-dialog-title"></h3>
<p id="likwid-dialog-message" class="ui-dialog-message"></p>
</div>
<div id="likwid-dialog-input-wrap" class="ui-dialog-input" style="display: none;">
<label class="ui-label" for="likwid-dialog-textarea" id="likwid-dialog-label"></label>
<textarea id="likwid-dialog-textarea" class="ui-textarea" rows="4"></textarea>
</div>
<div class="ui-dialog-actions">
<button id="likwid-dialog-cancel" class="ui-btn ui-btn-secondary" value="cancel" type="submit">Cancel</button>
<button id="likwid-dialog-confirm" class="ui-btn ui-btn-primary" value="confirm" type="submit">Confirm</button>
</div>
</form>
</dialog>
<script is:inline>
(function() {
const toastsEl = document.getElementById('likwid-toasts');
const dialogEl = document.getElementById('likwid-dialog');
const dialogTitleEl = document.getElementById('likwid-dialog-title');
const dialogMessageEl = document.getElementById('likwid-dialog-message');
const dialogInputWrapEl = document.getElementById('likwid-dialog-input-wrap');
const dialogLabelEl = document.getElementById('likwid-dialog-label');
const dialogTextareaEl = document.getElementById('likwid-dialog-textarea');
const dialogConfirmEl = document.getElementById('likwid-dialog-confirm');
const dialogCancelEl = document.getElementById('likwid-dialog-cancel');
function toast(type, message, opts) {
if (!toastsEl) return;
const options = opts || {};
const id = 't_' + Math.random().toString(16).slice(2);
const el = document.createElement('div');
el.className = 'ui-toast ui-toast-' + (type || 'info');
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
el.dataset.toastId = id;
const text = document.createElement('div');
text.className = 'ui-toast-text';
text.textContent = String(message || '');
const close = document.createElement('button');
close.type = 'button';
close.className = 'ui-toast-close';
close.setAttribute('aria-label', 'Dismiss notification');
close.textContent = '×';
close.addEventListener('click', () => {
el.remove();
});
el.appendChild(text);
el.appendChild(close);
toastsEl.appendChild(el);
const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 4500;
if (timeoutMs > 0) {
window.setTimeout(() => {
el.remove();
}, timeoutMs);
}
}
function openDialog(mode, opts) {
const options = opts || {};
if (!dialogEl || !dialogTitleEl || !dialogMessageEl || !dialogConfirmEl || !dialogCancelEl) {
return Promise.resolve(mode === 'prompt' ? null : false);
}
if (typeof dialogEl.showModal !== 'function') {
if (mode === 'prompt') {
return Promise.resolve(window.prompt(options.message || options.title || '', ''));
}
return Promise.resolve(window.confirm(options.message || options.title || 'Confirm?'));
}
dialogTitleEl.textContent = String(options.title || '');
dialogMessageEl.textContent = String(options.message || '');
const isPrompt = mode === 'prompt';
if (dialogInputWrapEl && dialogTextareaEl && dialogLabelEl) {
dialogInputWrapEl.style.display = isPrompt ? 'block' : 'none';
dialogLabelEl.textContent = String(options.label || '');
dialogTextareaEl.value = String(options.defaultValue || '');
dialogTextareaEl.placeholder = String(options.placeholder || '');
}
dialogConfirmEl.textContent = String(options.confirmText || (isPrompt ? 'Submit' : 'Confirm'));
dialogCancelEl.textContent = String(options.cancelText || 'Cancel');
return new Promise((resolve) => {
function cleanup() {
dialogEl.removeEventListener('close', onClose);
}
function onClose() {
const rv = dialogEl.returnValue;
if (mode === 'prompt') {
if (rv !== 'confirm') {
cleanup();
resolve(null);
return;
}
const v = dialogTextareaEl ? dialogTextareaEl.value : '';
cleanup();
resolve(v);
return;
}
cleanup();
resolve(rv === 'confirm');
}
dialogEl.addEventListener('close', onClose);
dialogEl.showModal();
if (mode === 'prompt' && dialogTextareaEl) {
dialogTextareaEl.focus();
} else {
dialogConfirmEl.focus();
}
});
}
window.likwidUi = window.likwidUi || {};
window.likwidUi.toast = toast;
window.likwidUi.confirm = function(opts) { return openDialog('confirm', opts); };
window.likwidUi.prompt = function(opts) { return openDialog('prompt', opts); };
})();
</script>
<style>
.ui-toasts {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 9999;
width: min(420px, calc(100vw - 2rem));
pointer-events: none;
}
.ui-toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.22);
}
.ui-toast-text {
color: var(--color-text);
font-size: 0.9375rem;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
}
.ui-toast-close {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-muted);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
padding: 0;
margin: -2px 0 0 0;
}
.ui-toast-close:hover {
color: var(--color-text);
}
.ui-toast-success {
border-color: color-mix(in srgb, var(--color-success) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-success-muted) 65%, var(--color-surface));
}
.ui-toast-error {
border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-error-muted) 65%, var(--color-surface));
}
.ui-toast-info {
border-color: color-mix(in srgb, var(--color-info) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-info-muted) 65%, var(--color-surface));
}
.ui-dialog {
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
width: min(560px, calc(100vw - 2rem));
}
.ui-dialog::backdrop {
background: var(--color-overlay);
}
.ui-dialog-form {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.ui-dialog-title {
margin: 0;
font-size: 1.05rem;
}
.ui-dialog-message {
margin: 0.25rem 0 0 0;
color: var(--color-text-muted);
font-size: 0.9375rem;
line-height: 1.4;
}
.ui-dialog-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ui-label {
font-weight: 600;
font-size: 0.9375rem;
}
.ui-textarea {
width: 100%;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-field-bg);
color: var(--color-text);
padding: 0.75rem;
font-size: 0.9375rem;
resize: vertical;
min-height: 88px;
}
.ui-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
</style>

View file

@ -7,6 +7,7 @@ import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes';
import { API_BASE as apiBase, SERVER_API_BASE } from '../lib/api'; import { API_BASE as apiBase, SERVER_API_BASE } from '../lib/api';
import VotingIcons from '../components/icons/VotingIcons.astro'; import VotingIcons from '../components/icons/VotingIcons.astro';
import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro'; import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro';
import FeedbackHost from '../components/ui/FeedbackHost.astro';
function isEnabled(v: string | undefined): boolean { function isEnabled(v: string | undefined): boolean {
if (!v) return false; if (!v) return false;
@ -96,6 +97,7 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
<body> <body>
<a class="skip-link" href="#main-content">Skip to content</a> <a class="skip-link" href="#main-content">Skip to content</a>
<VotingIcons /> <VotingIcons />
<FeedbackHost />
<div class="app"> <div class="app">
<header class="header"> <header class="header">
<nav class="nav" id="site-nav"> <nav class="nav" id="site-nav">

View file

@ -168,7 +168,15 @@ import { API_BASE as apiBase } from '../../lib/api';
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const type = btn.dataset.type; const type = btn.dataset.type;
const id = btn.dataset.id; const id = btn.dataset.id;
const reason = prompt('Rejection reason (optional):'); const reason = window.likwidUi?.prompt
? await window.likwidUi.prompt({
title: 'Reject request',
message: 'Rejection reason (optional)',
label: 'Reason',
placeholder: 'Optional',
confirmText: 'Reject',
})
: null;
await reviewItem(type, id, false, reason); await reviewItem(type, id, false, reason);
}); });
}); });
@ -195,7 +203,7 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
return; return;
} }
@ -208,7 +216,7 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
if (res.ok && data && data.success) { if (res.ok && data && data.success) {
alert(data.message); window.likwidUi?.toast?.('success', data.message);
// Remove item from list // Remove item from list
document.querySelector(`.pending-item[data-id="${id}"]`)?.remove(); document.querySelector(`.pending-item[data-id="${id}"]`)?.remove();
@ -221,10 +229,13 @@ import { API_BASE as apiBase } from '../../lib/api';
container.innerHTML = `<p class="empty">No pending ${type} requests</p>`; container.innerHTML = `<p class="empty">No pending ${type} requests</p>`;
} }
} else { } else {
alert('Error: ' + ((data && (data.message || data.error)) || raw || 'Failed to process')); window.likwidUi?.toast?.(
'error',
'Error: ' + ((data && (data.message || data.error)) || raw || 'Failed to process')
);
} }
} catch (error) { } catch (error) {
alert('Error processing request'); window.likwidUi?.toast?.('error', 'Error processing request');
} }
} }

View file

@ -93,21 +93,21 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
return; return;
} }
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
alert(`Invitation created!\nCode: ${data.code}`); window.likwidUi?.toast?.('success', `Invitation created!\nCode: ${data.code}`, { timeoutMs: 8000 });
(document.getElementById('email')).value = ''; (document.getElementById('email')).value = '';
loadInvitations(); loadInvitations();
} else { } else {
const err = await res.text(); const err = await res.text();
alert('Error: ' + err); window.likwidUi?.toast?.('error', 'Error: ' + err);
} }
} catch (error) { } catch (error) {
alert('Error creating invitation'); window.likwidUi?.toast?.('error', 'Error creating invitation');
} }
}); });
@ -169,13 +169,20 @@ import { API_BASE as apiBase } from '../../lib/api';
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const code = (btn).dataset.code; const code = (btn).dataset.code;
navigator.clipboard.writeText(code || ''); navigator.clipboard.writeText(code || '');
alert('Code copied to clipboard!'); window.likwidUi?.toast?.('success', 'Code copied to clipboard!');
}); });
}); });
document.querySelectorAll('.js-revoke').forEach(btn => { document.querySelectorAll('.js-revoke').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (!confirm('Revoke this invitation?')) return; const ok = window.likwidUi?.confirm
? await window.likwidUi.confirm({
title: 'Revoke invitation',
message: 'Revoke this invitation?',
confirmText: 'Revoke',
})
: false;
if (!ok) return;
const id = (btn).dataset.id; const id = (btn).dataset.id;
try { try {
const res = await fetch(`${apiBase}/api/invitations/${id}`, { const res = await fetch(`${apiBase}/api/invitations/${id}`, {
@ -189,17 +196,17 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
return; return;
} }
if (res.ok) { if (res.ok) {
loadInvitations(); loadInvitations();
} else { } else {
alert('Error: ' + (await res.text())); window.likwidUi?.toast?.('error', 'Error: ' + (await res.text()));
} }
} catch (error) { } catch (error) {
alert('Error revoking invitation'); window.likwidUi?.toast?.('error', 'Error revoking invitation');
} }
}); });
}); });

View file

@ -251,21 +251,22 @@ import { API_BASE } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
e.target.checked = !isEnabled; e.target.checked = !isEnabled;
return; return;
} }
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const err = await res.text();
alert('Failed to update plugin: ' + err); window.likwidUi?.toast?.('error', 'Failed to update plugin: ' + err);
e.target.checked = !isEnabled; e.target.checked = !isEnabled;
} else { } else {
const card = e.target.closest('.plugin-card'); const card = e.target.closest('.plugin-card');
card.classList.toggle('disabled', !isEnabled); card.classList.toggle('disabled', !isEnabled);
window.likwidUi?.toast?.('success', `Plugin ${isEnabled ? 'enabled' : 'disabled'}`);
} }
} catch (err) { } catch (err) {
alert('Failed to update plugin'); window.likwidUi?.toast?.('error', 'Failed to update plugin');
e.target.checked = !isEnabled; e.target.checked = !isEnabled;
} }
}); });

View file

@ -584,18 +584,21 @@ import { API_BASE } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
e.target.checked = !isActive; e.target.checked = !isActive;
return; return;
} }
if (!res.ok) { if (!res.ok) {
window.likwidUi?.toast?.('error', 'Failed to update voting method');
e.target.checked = !isActive; e.target.checked = !isActive;
} else { } else {
const row = e.target.closest('.method-row'); const row = e.target.closest('.method-row');
row.classList.toggle('disabled', !isActive); row.classList.toggle('disabled', !isActive);
window.likwidUi?.toast?.('success', `Voting method ${isActive ? 'enabled' : 'disabled'}`);
} }
} catch (err) { } catch (err) {
window.likwidUi?.toast?.('error', 'Failed to update voting method');
e.target.checked = !isActive; e.target.checked = !isActive;
} }
}); });
@ -622,15 +625,18 @@ import { API_BASE } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
return; return;
} }
if (res.ok) { if (res.ok) {
window.likwidUi?.toast?.('success', 'Default voting method updated');
loadVotingMethods(); loadVotingMethods();
} else {
window.likwidUi?.toast?.('error', 'Failed to set default voting method');
} }
} catch (err) { } catch (err) {
console.error('Failed to set default'); window.likwidUi?.toast?.('error', 'Failed to set default voting method');
} }
}); });
}); });