Files
webmail/components/admin/flash-message.tsx
T
Linus Rath 10b4d2a9db feat: rework the admin panel around a settings-style sidebar and one URL per page
The old panel kept its active tab in a store, so every page sat at
/admin, the sidebar duplicated the webmail rail by hand, and each page
was a stack of boxed cards with their own save buttons. Plugin settings
were only reachable through a gear icon in a row of five.

- one catch-all route for the whole panel; pages switch through
  history.pushState, so `/admin/plugins/<id>`, the back button, bookmarks
  and connector links all work without a route transition
- sidebar laid out like the user settings: back to mail, search over
  pages and their sections, two group headings, change password and
  sign out at the bottom; the phone shows a list, then the page
- pages use SettingsSection rows instead of nested cards, with one page
  header, one flash message and one save bar at the bottom (Discard,
  Save, Ctrl+S); the unsaved-changes prompt still guards leaving
- plugins and themes are lists of rows with a toggle each; a row opens
  the extension's own page with its settings, force-on, permissions,
  kept versions and removal
- overview shows stat tiles, server facts and recent activity
- old `/admin?tab=x` links redirect; the tab store is gone and the
  connector registry resolves admin links to the page URL
- `NEXT_TURBOPACK_ROOT` overrides the Turbopack root so `next dev` runs
  from a worktree with a junctioned node_modules
2026-10-20 15:16:05 +02:00

58 lines
1.9 KiB
TypeScript

'use client';
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import { CheckCircle2, AlertTriangle, X } from '@/components/icons';
import { cn } from '@/lib/utils';
export type FlashMessage = { type: 'success' | 'error'; text: string } | null;
/** Page-level feedback state; the message is shown with `<Flash>`. */
export function useFlash(): [FlashMessage, Dispatch<SetStateAction<FlashMessage>>] {
return useState<FlashMessage>(null);
}
interface FlashProps {
message: FlashMessage;
onDismiss: () => void;
className?: string;
}
/**
* The outcome of the last action, under the page header. A success goes
* away by itself; an error stays until dismissed or the next action.
*/
export function Flash({ message, onDismiss, className }: FlashProps) {
useEffect(() => {
if (message?.type !== 'success') return;
const timer = setTimeout(onDismiss, 6000);
return () => clearTimeout(timer);
}, [message, onDismiss]);
if (!message) return null;
const success = message.type === 'success';
const Icon = success ? CheckCircle2 : AlertTriangle;
return (
<div
role={success ? 'status' : 'alert'}
className={cn(
'mb-6 flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm',
success
? 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'border-destructive/30 bg-destructive/10 text-destructive',
className,
)}
>
<Icon className="w-4 h-4 shrink-0 mt-0.5" />
<span className="flex-1 min-w-0 break-words">{message.text}</span>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="shrink-0 -me-1 rounded p-0.5 opacity-70 hover:opacity-100 transition-opacity"
>
<X className="w-4 h-4" />
</button>
</div>
);
}