mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
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
71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, type ReactNode } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Loader2 } from '@/components/icons';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useAdminShell } from './shell-context';
|
|
|
|
interface AdminSaveBarProps {
|
|
dirty: boolean;
|
|
saving?: boolean;
|
|
onSave: () => void;
|
|
/** Drops the edits; without it the bar offers only Save. */
|
|
onDiscard?: () => void;
|
|
saveLabel?: string;
|
|
/** What the save does, e.g. "Users see the change on their next login". */
|
|
note?: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* The one place a page saves from. It appears at the bottom of the panel
|
|
* while something is unsaved (through the shell's portal host, so it sits
|
|
* below the scrolling content and above the mobile rail) and takes Ctrl+S.
|
|
*/
|
|
export function AdminSaveBar({ dirty, saving = false, onSave, onDiscard, saveLabel = 'Save changes', note }: AdminSaveBarProps) {
|
|
const host = useAdminShell()?.saveBarHost ?? null;
|
|
|
|
useEffect(() => {
|
|
if (!dirty) return;
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
|
event.preventDefault();
|
|
if (!saving) onSave();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
}, [dirty, saving, onSave]);
|
|
|
|
if (!dirty) return null;
|
|
|
|
const bar = (
|
|
<div
|
|
role="region"
|
|
aria-label="Unsaved changes"
|
|
data-testid="admin-save-bar"
|
|
className="border-t border-border bg-background px-4 py-3 sm:px-6"
|
|
>
|
|
<div className="mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0 text-sm">
|
|
<span className="font-medium text-foreground">Unsaved changes</span>
|
|
{note && <span className="text-muted-foreground"> · {note}</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{onDiscard && (
|
|
<Button type="button" variant="ghost" size="sm" onClick={onDiscard} disabled={saving}>
|
|
Discard
|
|
</Button>
|
|
)}
|
|
<Button type="button" size="sm" onClick={onSave} disabled={saving} className="gap-2">
|
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
{saveLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return host ? createPortal(bar, host) : bar;
|
|
}
|