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
149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
'use client';
|
|
|
|
import { useId, type ReactNode } from 'react';
|
|
import { RotateCcw } from '@/components/icons';
|
|
import { SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
|
import { cn } from '@/lib/utils';
|
|
import { Badge } from './badge';
|
|
|
|
/**
|
|
* Rows for the server configuration pages (Server, Authentication,
|
|
* Branding). Each shows where the value comes from and offers to drop an
|
|
* admin override; the page keeps the edits and saves them as one.
|
|
*/
|
|
|
|
export type ConfigSource = 'admin' | 'env' | 'default';
|
|
|
|
export const inputClass =
|
|
'h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground ' +
|
|
'hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring ' +
|
|
'disabled:cursor-not-allowed disabled:opacity-50 transition-colors';
|
|
|
|
export function SourceBadge({ source, label }: { source?: string; label?: string }) {
|
|
if (!source || source === 'default') return null;
|
|
return (
|
|
<Badge tone={source === 'admin' ? 'accent' : 'muted'} title={source === 'env' ? 'Set in the environment' : 'Set in this panel'}>
|
|
{label ?? source}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export function RevertButton({ onClick, title = 'Revert to default' }: { onClick: () => void; title?: string }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
title={title}
|
|
aria-label={title}
|
|
className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface ConfigItemBase {
|
|
label: string;
|
|
description?: string;
|
|
configKey: string;
|
|
source?: string;
|
|
onChange: (key: string, value: unknown) => void;
|
|
onRevert?: (key: string) => void;
|
|
/** Rows that only matter in unusual setups, hidden behind the Advanced switch. */
|
|
advanced?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface ConfigTextItemProps extends ConfigItemBase {
|
|
value: string | undefined | null;
|
|
placeholder?: string;
|
|
type?: 'text' | 'url' | 'password' | 'number';
|
|
mono?: boolean;
|
|
/** Wider field, for URLs and lists. */
|
|
wide?: boolean;
|
|
}
|
|
|
|
export function ConfigTextItem({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text', mono, wide, advanced, disabled }: ConfigTextItemProps) {
|
|
const id = useId();
|
|
return (
|
|
<SettingItem label={label} description={description} advanced={advanced}>
|
|
<div className="flex w-full items-center gap-1.5 sm:w-auto">
|
|
<SourceBadge source={source} />
|
|
<input
|
|
id={id}
|
|
type={type}
|
|
value={value ?? ''}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(configKey, e.target.value)}
|
|
placeholder={placeholder}
|
|
aria-label={label}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={cn(inputClass, 'w-full min-w-0', wide ? 'sm:w-80' : 'sm:w-64', mono && 'font-mono')}
|
|
/>
|
|
{source === 'admin' && onRevert && <RevertButton onClick={() => onRevert(configKey)} />}
|
|
</div>
|
|
</SettingItem>
|
|
);
|
|
}
|
|
|
|
interface ConfigToggleItemProps extends ConfigItemBase {
|
|
value: boolean | undefined | null;
|
|
}
|
|
|
|
export function ConfigToggleItem({ label, description, configKey, value, source, onChange, onRevert, advanced, disabled }: ConfigToggleItemProps) {
|
|
return (
|
|
<SettingItem label={label} description={description} advanced={advanced}>
|
|
<div className="flex items-center gap-1.5">
|
|
<SourceBadge source={source} />
|
|
<ToggleSwitch checked={!!value} disabled={disabled} onChange={(checked) => onChange(configKey, checked)} />
|
|
{source === 'admin' && onRevert && <RevertButton onClick={() => onRevert(configKey)} />}
|
|
</div>
|
|
</SettingItem>
|
|
);
|
|
}
|
|
|
|
interface ConfigSelectItemProps extends ConfigItemBase {
|
|
value: string | undefined | null;
|
|
options: ReadonlyArray<string | { value: string; label: string }>;
|
|
}
|
|
|
|
export function ConfigSelectItem({ label, description, configKey, value, source, options, onChange, onRevert, advanced, disabled }: ConfigSelectItemProps) {
|
|
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
return (
|
|
<SettingItem label={label} description={description} advanced={advanced}>
|
|
<div className="flex items-center gap-1.5">
|
|
<SourceBadge source={source} />
|
|
<select
|
|
value={value ?? ''}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(configKey, e.target.value)}
|
|
aria-label={label}
|
|
className={cn(inputClass, 'pe-8 cursor-pointer')}
|
|
>
|
|
{normalized.map((o) => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
{source === 'admin' && onRevert && <RevertButton onClick={() => onRevert(configKey)} />}
|
|
</div>
|
|
</SettingItem>
|
|
);
|
|
}
|
|
|
|
/** A note under a group of rows, for a warning or an explanation. */
|
|
export function ConfigNote({ tone = 'info', children }: { tone?: 'info' | 'warning'; children: ReactNode }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'rounded-md px-3 py-2.5 text-xs leading-relaxed',
|
|
tone === 'warning'
|
|
? 'border-s-2 border-amber-400 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-950/30 dark:text-amber-300'
|
|
: 'bg-muted/50 text-muted-foreground',
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|