Redesign the frontend UI on twui

Rebuild every page on a shared set of twui-based primitives with a dense
ops-console look, WireGuard red accent and light/dark/system theming.

- Add design tokens (WireGuard red accent, zinc neutrals, theme-flipping
  semantic colours) and a no-flash theme cookie + toggle
- Add shared components: page header, panel, sub-nav, data table with
  mobile card list, stat tile, status dot, key-value list, copy button,
  inline alert, empty state, form section/actions, confirm dialog and
  toast providers
- New admin shell (sidebar, sticky header, mobile drawer) and a centered
  shell for auth and 404; strip the public template pages
- Unify adding clients at /admin/clients/add with a host picker; tabbed
  client detail (Overview / Rules / Config & QR) with config copy/download
- Re-run client setup after rule changes on the detail page so the
  firewall matches the DB
- Split Setup into status/maintenance/firewall and Settings into
  profile/password subpages
- Replace window.confirm/alert and page reloads with in-app dialogs,
  toasts and in-place list updates
- Fix nested rule form submitting the client form, prefilled signup
  passwords, hidden submit buttons, fields not updating after "next
  free" lookups, and TinyMCE white-on-white in dark mode

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-16 06:29:21 +01:00
co-authored by Claude Opus 5.5
parent e50b0405b0
commit cad1d32ae4
291 changed files with 6436 additions and 6612 deletions
@@ -0,0 +1,30 @@
type Params = {
is_dark: boolean;
};
/**
* CSS for the TinyMCE content iframe. The iframe is its own document, so it
* doesn't see the app's tokens. Its `color-scheme` must match the page's:
* when they differ the browser paints the iframe with an opaque white canvas,
* which made dark-mode text white on white.
*/
export default function buildRteContentStyle({ is_dark }: Params) {
const background = is_dark ? "#0f0f11" : "#ffffff";
const color = is_dark ? "#fafafa" : "#18181b";
const placeholder = is_dark ? "#71717a" : "#a1a1aa";
const link = is_dark ? "#ef7a7e" : "#89161b";
return `
:root { color-scheme: ${is_dark ? "dark" : "light"}; }
body {
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
font-size: 15px;
line-height: 1.6;
background-color: ${background};
color: ${color};
margin: 12px 14px;
}
a { color: ${link}; }
.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before { color: ${placeholder}; }
`;
}
@@ -0,0 +1,18 @@
import type { ThemeMode } from "@/src/dict/theme-dict";
type Params = {
mode: ThemeMode;
};
/**
* Toggles the `.dark` class on `<html>` for the given mode. Only touches that
* one class so other classes on the element survive.
*/
export default function applyTheme({ mode }: Params) {
const dark =
mode == "dark" ||
(mode == "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
}
@@ -0,0 +1,13 @@
import {
ThemeCookieMaxAge,
ThemeCookieName,
type ThemeMode,
} from "@/src/dict/theme-dict";
type Params = {
mode: ThemeMode;
};
export default function setThemeCookie({ mode }: Params) {
document.cookie = `${ThemeCookieName}=${mode}; Path=/; SameSite=Lax; Max-Age=${ThemeCookieMaxAge}`;
}