refactor: make admin panel mobile friendly

This commit is contained in:
Linus Rath
2026-05-01 20:22:29 +02:00
parent eecf16daa2
commit 9a5bb78b18
12 changed files with 354 additions and 186 deletions
+12 -12
View File
@@ -129,8 +129,8 @@ export default function AdminAuthPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1> <h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p> <p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
</div> </div>
@@ -154,7 +154,7 @@ export default function AdminAuthPage() {
{/* Auto-setup */} {/* Auto-setup */}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4"> <div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex items-start justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" /> <Sparkles className="w-4 h-4 text-primary shrink-0" />
@@ -314,7 +314,7 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -322,11 +322,11 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder} <input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
{source === 'admin' && ( {source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button> <button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)} )}
</div> </div>
</div> </div>
@@ -338,7 +338,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -346,7 +346,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button onClick={() => onChange(configKey, !value)} <button onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
@@ -364,12 +364,12 @@ function Select({ label, configKey, value, source, options, onChange, onRevert }
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} <select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"> className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{options.map(o => <option key={o} value={o}>{o}</option>)} {options.map(o => <option key={o} value={o}>{o}</option>)}
+10 -10
View File
@@ -162,8 +162,8 @@ export default function AdminBrandingPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Branding</h1> <h1 className="text-2xl font-semibold text-foreground">Branding</h1>
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p> <p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
</div> </div>
@@ -193,22 +193,22 @@ export default function AdminBrandingPage() {
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{IMAGE_FIELDS.map(field => ( {IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3"> <div key={field.key} className="px-4 py-3">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label> <label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary"> <span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'} {isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={currentValue(field.key)} value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)} onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file" placeholder="Enter URL or upload a file"
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
<input <input
ref={el => { fileInputRefs.current[field.key] = el; }} ref={el => { fileInputRefs.current[field.key] = el; }}
@@ -270,20 +270,20 @@ export default function AdminBrandingPage() {
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{TEXT_FIELDS.map(field => ( {TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label> <label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span> <span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={currentValue(field.key)} value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)} onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'} placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default"> <button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
+201 -63
View File
@@ -21,6 +21,8 @@ import {
HardDrive, HardDrive,
ArrowLeft, ArrowLeft,
Store, Store,
Menu,
X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
@@ -69,12 +71,26 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [authenticated, setAuthenticated] = useState<boolean | null>(null); const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null); const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' const logoUrl = resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
useEffect(() => {
setMobileNavOpen(false);
}, [pathname]);
useEffect(() => {
if (!mobileNavOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [mobileNavOpen]);
useEffect(() => { useEffect(() => {
if (pathname === '/admin/login') return; if (pathname === '/admin/login') return;
let cancelled = false; let cancelled = false;
@@ -140,10 +156,77 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return <>{children}</>; return <>{children}</>;
} }
const navContent = (
<>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{label}
</Link>
);
})}
</div>
))}
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
</>
);
return ( return (
<div className="min-h-screen flex bg-background"> <div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */} {/* Slim webmail nav rail (desktop only) */}
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0"> <nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
{logoUrl ? ( {logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" /> <img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : ( ) : (
@@ -191,8 +274,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div> </div>
</nav> </nav>
{/* Admin Sidebar */} {/* Admin Sidebar (desktop only) */}
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen"> <aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0"> <div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? ( {logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" /> <img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
@@ -201,74 +284,71 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
)} )}
<span className="font-semibold text-sm text-foreground">Admin Panel</span> <span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div> </div>
{navContent}
</aside>
<div className="flex-1 overflow-y-auto py-2"> {/* Mobile drawer overlay */}
<div className="px-2 space-y-0.5"> {mobileNavOpen && (
{NAV_GROUPS.map((group, groupIndex) => ( <div
<div key={group.label}> className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />} onClick={() => setMobileNavOpen(false)}
<div className="px-3 pt-2.5 pb-1"> aria-hidden="true"
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> />
{group.label} )}
</span>
</div> {/* Mobile drawer */}
{group.items.map(({ href, label, icon: Icon }) => { <aside
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href); className={cn(
return ( 'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
<Link mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
key={href} )}
href={href} aria-label="Admin navigation"
className={cn( aria-hidden={!mobileNavOpen}
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5', >
active <div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
? 'bg-accent text-accent-foreground font-medium' <div className="flex items-center min-w-0">
: 'hover:bg-muted text-foreground' {logoUrl ? (
)} <img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
> ) : (
<Icon className={cn( <Shield className="w-5 h-5 text-primary mr-2" />
'w-4 h-4 shrink-0', )}
active ? 'text-accent-foreground' : 'text-muted-foreground' <span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
)} />
{label}
</Link>
);
})}
</div>
))}
</div> </div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button <button
onClick={handleLogout} type="button"
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground" onClick={() => setMobileNavOpen(false)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close navigation"
> >
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" /> <X className="w-5 h-5" />
Sign out
</button> </button>
</div> </div>
{navContent}
</aside> </aside>
{/* Main content */} {/* Main content */}
<main className="flex-1 overflow-auto"> <main className="flex-1 min-w-0 overflow-x-hidden">
<div className="max-w-4xl mx-auto p-6"> {/* Mobile header */}
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
aria-label="Open navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
</div>
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
{authError ? ( {authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"> <div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p> <p className="font-medium">Admin authentication failed</p>
@@ -283,6 +363,64 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
) : null} ) : null}
</div> </div>
</main> </main>
{/* Mobile bottom nav (main webmail nav) */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Main navigation"
>
<a
href="/"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
<Mail className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href="/calendar"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
<Calendar className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href="/contacts"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
<a
href="/files"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
aria-current="page"
>
<div className="relative">
<Shield className="w-5 h-5" />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href="/settings"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
</a>
</nav>
</div> </div>
); );
} }
+36 -8
View File
@@ -33,8 +33,8 @@ export default function AdminLogsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1> <h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p> <p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
</div> </div>
@@ -52,7 +52,7 @@ export default function AdminLogsPage() {
<select <select
value={actionFilter} value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }} onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<option value="">All actions</option> <option value="">All actions</option>
<option value="admin.login">Login</option> <option value="admin.login">Login</option>
@@ -66,15 +66,43 @@ export default function AdminLogsPage() {
</select> </select>
</div> </div>
{/* Table */} {/* Mobile cards */}
<div className="border border-border rounded-lg overflow-hidden"> <div className="sm:hidden space-y-2">
{loading && entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
) : (
entries.map((entry, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
{entry.action}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</span>
</div>
<div className="text-xs text-foreground break-words">
{formatDetail(entry.detail)}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{entry.ip}
</div>
</div>
))
)}
</div>
{/* Desktop table */}
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-muted/30"> <tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th> <th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th> <th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th> <th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th> <th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
+36 -34
View File
@@ -210,46 +210,48 @@ export default function MarketplacePreviewPage() {
</Link> </Link>
{/* Header */} {/* Header */}
<div className="flex items-start gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0"> <div className="flex items-start gap-4 flex-1 min-w-0">
{isPlugin ? ( <div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Puzzle className="w-7 h-7 text-muted-foreground" /> {isPlugin ? (
) : ( <Puzzle className="w-7 h-7 text-muted-foreground" />
<SwatchBook className="w-7 h-7 text-muted-foreground" /> ) : (
)} <SwatchBook className="w-7 h-7 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
<Check className="w-3 h-3" /> Installed
</span>
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap"> <div className="flex-1 min-w-0">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
isPlugin <h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400' {ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400' {data.installed && (
}`}> <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'} <Check className="w-3 h-3" /> Installed
</span> </span>
{ext.author && ( )}
<span>by {ext.author.displayName}</span> </div>
)} <div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
{ext.latestVersion && <span>v{ext.latestVersion}</span>} <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
{ext.license && <span>{ext.license}</span>} isPlugin
<span className="inline-flex items-center gap-1"> ? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
<Download className="w-3 h-3" /> : 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
{ext.totalDownloads.toLocaleString()} }`}>
</span> {isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span>by {ext.author.displayName}</span>
)}
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
</div>
</div> </div>
</div> </div>
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? ( {data.installed ? (
<> <>
<Link <Link
+3 -3
View File
@@ -142,8 +142,8 @@ export default function AdminMarketplacePage() {
)} )}
{/* Search & Filters */} {/* Search & Filters */}
<div className="flex items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="relative flex-1"> <div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="text" type="text"
@@ -153,7 +153,7 @@ export default function AdminMarketplacePage() {
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/> />
</div> </div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5"> <div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
{(['all', 'plugin', 'theme'] as const).map((t) => ( {(['all', 'plugin', 'theme'] as const).map((t) => (
<button <button
key={t} key={t}
+9 -9
View File
@@ -262,12 +262,12 @@ export default function AdminPluginsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1> <h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p> <p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{policyDirty && ( {policyDirty && (
<button <button
onClick={handleSavePolicy} onClick={handleSavePolicy}
@@ -309,7 +309,7 @@ export default function AdminPluginsPage() {
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p> <p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Plugins Enabled</span> <span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p> <p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
@@ -320,7 +320,7 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </div>
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">User Plugin Uploads</span> <span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
@@ -331,7 +331,7 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </div>
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Require Admin Approval</span> <span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p> <p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
@@ -344,7 +344,7 @@ export default function AdminPluginsPage() {
{/* Force enable / disable all */} {/* Force enable / disable all */}
{plugins.length > 0 && ( {plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Force Enable / Disable All</span> <span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p> <p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
@@ -388,9 +388,9 @@ export default function AdminPluginsPage() {
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{plugins.map(plugin => ( {plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4"> <div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{plugin.name}</span> <span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span> <span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}> <span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+7 -7
View File
@@ -132,8 +132,8 @@ export default function AdminPolicyPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1> <h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p> <p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
</div> </div>
@@ -170,13 +170,13 @@ export default function AdminPolicyPage() {
const { label, description } = meta; const { label, description } = meta;
const enabled = policy.features[key]; const enabled = policy.features[key];
return ( return (
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p> <p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div> </div>
<button onClick={() => toggleFeature(key)} <button onClick={() => toggleFeature(key)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
@@ -195,9 +195,9 @@ export default function AdminPolicyPage() {
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {}; const restriction = policy.restrictions[setting.key] || {};
return ( return (
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{setting.label}</span> <span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 shrink-0">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)} <input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" /> className="rounded border-input" />
+12 -12
View File
@@ -86,8 +86,8 @@ export default function AdminSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1> <h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
<p className="text-sm text-muted-foreground mt-1">General server configuration</p> <p className="text-sm text-muted-foreground mt-1">General server configuration</p>
</div> </div>
@@ -166,21 +166,21 @@ function TextSetting({ label, configKey, value, source, onChange, onRevert, plac
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{label}</label> <label className="text-sm text-foreground">{label}</label>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)} onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder} placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
{source === 'admin' && ( {source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default"> <button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
</button> </button>
)} )}
@@ -194,7 +194,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -202,7 +202,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button <button
onClick={() => onChange(configKey, !value)} onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
@@ -224,12 +224,12 @@ function SelectSetting({ label, configKey, value, source, options, onChange, onR
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<select <select
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)} onChange={(e) => onChange(configKey, e.target.value)}
+9 -9
View File
@@ -114,7 +114,7 @@ export default function AdminTelemetryPage() {
const isOn = status.consent === 'on'; const isOn = status.consent === 'on';
return ( return (
<div className="max-w-3xl mx-auto p-6 space-y-6"> <div className="space-y-6">
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1> <h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -133,8 +133,8 @@ export default function AdminTelemetryPage() {
</header> </header>
<section className="rounded-lg border p-4 space-y-3"> <section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div className="min-w-0">
<div className="font-medium">Status</div> <div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'} {status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
@@ -195,19 +195,19 @@ export default function AdminTelemetryPage() {
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending. (open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p> </p>
<div className="flex gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<input <input
type="url" type="url"
value={endpointDraft} value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)} onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint} placeholder={status.defaultEndpoint}
className="flex-1 px-3 py-1.5 rounded-md border bg-background" className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
/> />
<button <button
type="button" type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint} disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()} onClick={() => void saveEndpoint()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1" className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
> >
<Save className="h-4 w-4" /> Save <Save className="h-4 w-4" /> Save
</button> </button>
@@ -215,8 +215,8 @@ export default function AdminTelemetryPage() {
</section> </section>
<section className="rounded-lg border p-4 space-y-3"> <section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div className="min-w-0">
<div className="font-medium">Payload preview</div> <div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now. Exactly what the next heartbeat would send from this install, right now.
@@ -226,7 +226,7 @@ export default function AdminTelemetryPage() {
type="button" type="button"
disabled={busy === 'send' || !isOn} disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()} onClick={() => void sendNow()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1" className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
> >
<Send className="h-4 w-4" /> Send now <Send className="h-4 w-4" /> Send now
</button> </button>
+17 -17
View File
@@ -307,12 +307,12 @@ export default function AdminThemesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Themes</h1> <h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p> <p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{policyDirty && ( {policyDirty && (
<button <button
onClick={handleSavePolicy} onClick={handleSavePolicy}
@@ -356,37 +356,37 @@ export default function AdminThemesPage() {
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{/* Master toggle */} {/* Master toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">Themes Enabled</span> <span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div> </div>
<button onClick={toggleThemesEnabled} <button onClick={toggleThemesEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
{/* User uploads toggle */} {/* User uploads toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">User Theme Uploads</span> <span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div> </div>
<button onClick={toggleUserThemeUploads} <button onClick={toggleUserThemeUploads}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
{/* Force enable / disable all */} {/* Force enable / disable all */}
{themes.length > 0 && ( {themes.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">Force Enable / Disable All</span> <span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p> <p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button <button
onClick={forceEnableAll} onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors" className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
@@ -407,15 +407,15 @@ export default function AdminThemesPage() {
{/* Default Theme */} {/* Default Theme */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">Default Theme</span> <span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p> <p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div> </div>
<select <select
value={policy.themePolicy?.defaultThemeId || ''} value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)} onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground" className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
> >
<option value="">System Default</option> <option value="">System Default</option>
<optgroup label="Built-in"> <optgroup label="Built-in">
@@ -498,9 +498,9 @@ export default function AdminThemesPage() {
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{themes.map(theme => ( {themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4"> <div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{theme.name}</span> <span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span> <span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}> <span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+2 -2
View File
@@ -44,8 +44,8 @@ interface SettingItemProps {
export function SettingItem({ label, description, children, locked }: SettingItemProps) { export function SettingItem({ label, description, children, locked }: SettingItemProps) {
return ( return (
<div className={cn("flex items-start justify-between py-3 border-b border-border last:border-0", locked && "opacity-60")}> <div className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}>
<div className="flex-1 pr-4"> <div className="flex-1 min-w-0 sm:pr-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label> <label className="text-sm font-medium text-foreground">{label}</label>
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />} {locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}