design(verwaltung): Einstellungen und Verwaltung aufgeraeumt

Gemeinsame Navigation fuer Einstellungen und Verwaltung (Verwaltung in
Benutzer und Zugang / Module / Anbindungen gegliedert), einheitlicher
Seitenkopf und Abschnittskarten, Speichern in der Kartenfusszeile.
Menuepunkt 'Administrator' heisst 'Verwaltung'; Seitentitel gleich den
Navigationseintraegen. Kein 'Zurueck zum Dashboard' mehr. Tabellen ohne
waagerechte Seitenscrollleiste; auf dem Handy Auswahlliste statt Liste.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-05 13:36:05 +02:00
parent 831c7b8f52
commit 0a35a32afb
32 changed files with 2579 additions and 2170 deletions
@@ -1,147 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
export function AdminSidebar() {
const t = useTranslations('header');
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
const items = [
{
label: t('admin.users'),
href: '/admin/users',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
},
{
label: t('admin.tenants'),
href: '/admin/tenants',
show: isSuperAdmin,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
},
{
label: t('admin.modules'),
href: '/admin/modules',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
),
},
{
label: t('admin.customModules'),
href: '/admin/custom-modules',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
),
},
{
label: t('admin.ldap'),
href: '/admin/ldap',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
},
{
label: t('admin.smtp'),
href: '/admin/smtp',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
),
},
{
label: t('admin.welcomeMail'),
href: '/admin/welcome-mail',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
<path d="m16 19 2 2 4-4" />
</svg>
),
},
{
label: t('admin.groups'),
href: '/admin/groups',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" />
<line x1="8" x2="21" y1="12" y2="12" />
<line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" />
<line x1="3" x2="3.01" y1="12" y2="12" />
<line x1="3" x2="3.01" y1="18" y2="18" />
</svg>
),
},
];
return (
// a11y/useSemanticElements: role="navigation" nicht auf das <aside> gehoben --
// das <nav> darunter trAegt die Navigationssemantik schon; ein zweites
// Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer
// dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>.
<aside className="flex w-full shrink-0 flex-col border-b border-border py-2 md:w-[220px] md:border-b-0">
<div className="px-5 pb-1 pt-4">
<h2 className="text-xs font-semibold text-muted-foreground">
{t('admin.label')}
</h2>
</div>
<nav className="flex flex-col gap-0.5 px-3" aria-label={t('admin.label')}>
{items
.filter((item) => item.show)
.map((item) => (
<Link
key={item.href}
href={item.href}
className={`nav-item flex h-9 items-center gap-2 rounded-lg px-2.5 text-sm transition-colors ${
pathname.startsWith(item.href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={pathname.startsWith(item.href) ? 'page' : undefined}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
</aside>
);
}
@@ -19,6 +19,8 @@ import {
WelcomeMailTemplateRequestError,
type WelcomeMailTemplateState,
} from '@/lib/welcome-mail-template-api';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
type Field = keyof WelcomeMailTexts;
type PreviewAccount = 'directory' | 'local';
@@ -79,6 +81,7 @@ const PREVIEW_DELAY_MS = 400;
*/
export function WelcomeMailTemplateEditor() {
const t = useTranslations('admin.welcomeMail');
const tCc = useTranslations('controlCenter');
const locale = useLocale();
const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
@@ -255,258 +258,267 @@ export function WelcomeMailTemplateEditor() {
: t('statusDefault');
return (
<div className="space-y-6">
<div>
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
<div>
<PageHeader title={t('title')} description={t('description')} />
{/* Feste Bausteine: ruhiger Hinweis direkt unter dem Kopf */}
<div className="mb-6 max-w-3xl rounded-md bg-muted px-4 py-3 text-sm">
<p className="mb-1 font-medium text-foreground">{t('fixed.title')}</p>
<p className="text-muted-foreground">{t('fixed.body')}</p>
</div>
{/* Feste Bausteine */}
<section
aria-labelledby="welcome-fixed-title"
className="max-w-3xl rounded-lg border border-border bg-muted/40 p-4"
>
<h2 id="welcome-fixed-title" className="mb-1 text-sm font-semibold text-foreground">
{t('fixed.title')}
</h2>
<p className="text-sm text-muted-foreground">{t('fixed.body')}</p>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
{/* Formular */}
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate>
<p
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`}
data-testid="welcome-template-status"
>
{statusText}
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
</p>
{FIELDS.map((field) => {
const id = `welcome-${field}`;
const errors = errorsByField[field];
const describedBy = [`${id}-help`, errors.length ? `${id}-error` : '']
.filter(Boolean)
.join(' ');
const common = {
id,
value: form[field],
'aria-invalid': errors.length > 0 || undefined,
'aria-describedby': describedBy,
onFocus: () => {
lastFocused.current = field;
const hintAccount = HINT_ACCOUNT[field];
if (hintAccount) setAccount(hintAccount);
},
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
},
};
return (
<div key={field}>
<label htmlFor={id} className="mb-1 block text-sm text-foreground">
{t(`fields.${field}`)}
</label>
{MULTILINE[field] ? (
<textarea
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
rows={ROWS[field] ?? 5}
className={`${inputClass} py-2`}
/>
) : (
<input
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
type="text"
className={`${inputClass} h-9`}
/>
)}
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
<span className="shrink-0 tabular-nums">
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
</span>
</div>
{errors.length > 0 && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
{errors.join(' ')}
</p>
)}
</div>
);
})}
<div className="flex flex-wrap gap-3 pt-2">
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
<button
type="button"
disabled={testing || hasErrors}
onClick={handleTest}
className="btn btn-secondary"
>
{testing ? t('testSending') : t('test')}
</button>
<button
type="button"
disabled={resetting}
onClick={() => setConfirmReset(true)}
className="btn btn-secondary"
>
{t('reset')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
{confirmReset && (
<div
role="alertdialog"
aria-labelledby="welcome-reset-title"
aria-describedby="welcome-reset-body"
className="max-w-xl rounded-lg border border-border bg-background p-4"
>
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
{t('resetConfirm.title')}
</h2>
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
{t('resetConfirm.body')}
</p>
<div className="flex gap-3">
<form onSubmit={handleSave} className="min-w-0" noValidate>
<SettingsSection
id="welcome-texts"
title={tCc('welcomeMail.textsTitle')}
description={
<span
className={state.custom ? 'text-foreground' : undefined}
data-testid="welcome-template-status"
>
{statusText}
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
</span>
}
footer={
<>
<button
type="button"
disabled={resetting}
onClick={handleReset}
className="btn btn-primary"
onClick={() => setConfirmReset(true)}
className="btn btn-subtle"
>
{t('resetConfirm.confirm')}
{t('reset')}
</button>
<button
type="button"
onClick={() => setConfirmReset(false)}
disabled={testing || hasErrors}
onClick={handleTest}
className="btn btn-secondary"
>
{t('resetConfirm.cancel')}
{testing ? t('testSending') : t('test')}
</button>
</div>
</div>
)}
{feedback && (
<p
role={feedback.type === 'error' ? 'alert' : 'status'}
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
>
{feedback.message}
</p>
)}
{/* Platzhalter */}
<section
aria-labelledby="welcome-placeholders-title"
className="rounded-lg border border-border p-4"
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<h2
id="welcome-placeholders-title"
className="mb-1 text-sm font-semibold text-foreground"
>
{t('placeholders.title')}
</h2>
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colPlaceholder')}
</th>
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colMeaning')}
</th>
<th scope="col" className="py-2 font-medium">
{t('placeholders.colExample')}
</th>
</tr>
</thead>
<tbody>
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
<tr key={name} className="border-b border-border last:border-0 align-top">
<td className="py-2 pr-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertPlaceholder(name)}
title={t('placeholders.insertHint')}
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
>
{`{{${name}}}`}
</button>
</td>
<td className="py-2 pr-3 text-foreground">
{t(`placeholders.items.${name}.meaning`)}
</td>
<td className="py-2 text-muted-foreground">
{t(`placeholders.items.${name}.example`)}
</td>
</tr>
))}
</tbody>
</table>
<div className="space-y-4">
{FIELDS.map((field) => {
const id = `welcome-${field}`;
const errors = errorsByField[field];
const describedBy = [`${id}-help`, errors.length ? `${id}-error` : '']
.filter(Boolean)
.join(' ');
const common = {
id,
value: form[field],
'aria-invalid': errors.length > 0 || undefined,
'aria-describedby': describedBy,
onFocus: () => {
lastFocused.current = field;
const hintAccount = HINT_ACCOUNT[field];
if (hintAccount) setAccount(hintAccount);
},
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
},
};
return (
<div key={field}>
<label htmlFor={id} className="mb-1 block text-sm text-foreground">
{t(`fields.${field}`)}
</label>
{MULTILINE[field] ? (
<textarea
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
rows={ROWS[field] ?? 5}
className={`${inputClass} py-2`}
/>
) : (
<input
{...common}
ref={(el) => {
fieldRefs.current[field] = el;
}}
type="text"
className={`${inputClass} h-9`}
/>
)}
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
<span className="shrink-0 tabular-nums">
{t('counter', {
count: form[field].length,
max: WELCOME_MAIL_LIMITS[field],
})}
</span>
</div>
{errors.length > 0 && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive">
{errors.join(' ')}
</p>
)}
</div>
);
})}
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
{confirmReset && (
<div
role="alertdialog"
aria-labelledby="welcome-reset-title"
aria-describedby="welcome-reset-body"
className="max-w-xl rounded-lg border border-border bg-background p-4"
>
<h2
id="welcome-reset-title"
className="mb-1 text-sm font-semibold text-foreground"
>
{t('resetConfirm.title')}
</h2>
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
{t('resetConfirm.body')}
</p>
<div className="flex gap-3">
<button
type="button"
disabled={resetting}
onClick={handleReset}
className="btn btn-secondary"
>
{t('resetConfirm.confirm')}
</button>
<button
type="button"
onClick={() => setConfirmReset(false)}
className="btn btn-secondary"
>
{t('resetConfirm.cancel')}
</button>
</div>
</div>
)}
{feedback && (
<p
role={feedback.type === 'error' ? 'alert' : 'status'}
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
style={
feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined
}
>
{feedback.message}
</p>
)}
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
</section>
</SettingsSection>
</form>
{/* Vorschau */}
<section aria-labelledby="welcome-preview-title" className="min-w-0">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
{t('preview.title')}
</h2>
<fieldset className="flex gap-1">
<legend className="sr-only">{t('preview.accountLabel')}</legend>
{(['directory', 'local'] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={account === kind}
onClick={() => setAccount(kind)}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
account === kind
? 'border-foreground bg-foreground text-background'
: 'border-border text-foreground hover:bg-sidebar-hover'
}`}
>
{t(`preview.${kind}`)}
</button>
<div className="min-w-0">
<SettingsSection
id="welcome-preview"
title={t('preview.title')}
actions={
<fieldset className="flex gap-1">
<legend className="sr-only">{t('preview.accountLabel')}</legend>
{(['directory', 'local'] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={account === kind}
onClick={() => setAccount(kind)}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
account === kind
? 'border-foreground bg-foreground text-background'
: 'border-border text-foreground hover:bg-sidebar-hover'
}`}
>
{t(`preview.${kind}`)}
</button>
))}
</fieldset>
}
>
<p className="mb-2 text-sm text-muted-foreground">
{t('preview.subject')}{' '}
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
{preview?.subject ?? ''}
</span>
</p>
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
<iframe
title={t('preview.frameTitle')}
sandbox=""
srcDoc={preview?.html ?? ''}
className="h-[760px] w-full rounded-lg border border-border bg-white"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
</SettingsSection>
</div>
</div>
<div className="mt-4">
{/* Platzhalter */}
<SettingsSection
id="welcome-placeholders"
title={t('placeholders.title')}
description={t('placeholders.intro')}
>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colPlaceholder')}
</th>
<th scope="col" className="py-2 pr-3 font-medium">
{t('placeholders.colMeaning')}
</th>
<th scope="col" className="py-2 font-medium">
{t('placeholders.colExample')}
</th>
</tr>
</thead>
<tbody>
{WELCOME_MAIL_PLACEHOLDERS.map((name) => (
<tr key={name} className="border-b border-border last:border-0 align-top">
<td className="py-2 pr-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertPlaceholder(name)}
title={t('placeholders.insertHint')}
aria-label={t('placeholders.insert', { token: `{{${name}}}` })}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground hover:bg-sidebar-hover"
>
{`{{${name}}}`}
</button>
</td>
<td className="py-2 pr-3 text-foreground">
{t(`placeholders.items.${name}.meaning`)}
</td>
<td className="py-2 text-muted-foreground">
{t(`placeholders.items.${name}.example`)}
</td>
</tr>
))}
</fieldset>
</div>
<p className="mb-2 text-sm text-muted-foreground">
{t('preview.subject')}{' '}
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
{preview?.subject ?? ''}
</span>
</p>
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
<iframe
title={t('preview.frameTitle')}
sandbox=""
srcDoc={preview?.html ?? ''}
className="h-[760px] w-full rounded-lg border border-border bg-white"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
</section>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
</SettingsSection>
</div>
</div>
);
@@ -0,0 +1,23 @@
'use client';
import type { ReactNode } from 'react';
import { ControlCenterNav } from './control-center-nav';
/**
* Rahmen fuer Einstellungen UND Verwaltung: links die gemeinsame
* Navigation, rechts der Inhalt mit begrenzter Lesebreite. Ersetzt die
* zwei getrennten Layouts samt „Zurueck zum Dashboard“-Link auf jeder
* Seite (das Dashboard steht ohnehin oben in der App-Seitenleiste).
*/
export function ControlCenterLayout({ children }: { children: ReactNode }) {
return (
<div className="flex h-full flex-col md:flex-row">
<aside className="cc-aside">
<ControlCenterNav />
</aside>
<div className="min-w-0 flex-1 overflow-y-auto">
<div className="cc-content">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,278 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import type { ReactNode } from 'react';
import { useAuthStore } from '@/lib/stores/auth-store';
/**
* Gemeinsame Navigation fuer Einstellungen und Verwaltung (Design-Runde
* „Verwaltung aufgeraeumt“). Vorher hatten beide Bereiche je eine eigene,
* unterschiedlich gebaute Seitenleiste (eine mit, eine ohne Symbole, die
* Verwaltung als flache Liste mit acht Eintraegen). Jetzt: ein Aufbau,
* oben die persoenlichen Einstellungen, darunter — nur fuer Administratoren
* — die Verwaltung in drei Gruppen nach Aufgabe.
*/
interface NavItem {
href: string;
label: string;
icon: ReactNode;
/** Exakter Treffer statt Praefix (fuer Eltern-Routen mit eigenen Unterseiten). */
exact?: boolean;
show?: boolean;
}
interface NavGroup {
id: string;
label: string;
items: NavItem[];
}
const ICON_PROPS = {
'aria-hidden': true,
width: 16,
height: 16,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.75,
strokeLinecap: 'round',
strokeLinejoin: 'round',
} as const;
const ICONS = {
account: (
<svg {...ICON_PROPS}>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
),
widgets: (
<svg {...ICON_PROPS}>
<rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" />
<rect x="3" y="16" width="7" height="5" rx="1.5" />
</svg>
),
calendar: (
<svg {...ICON_PROPS}>
<rect x="3" y="4" width="18" height="17" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
),
link: (
<svg {...ICON_PROPS}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
),
desktop: (
<svg {...ICON_PROPS}>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
),
users: (
<svg {...ICON_PROPS}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
groups: (
<svg {...ICON_PROPS}>
<circle cx="7" cy="8" r="3" />
<circle cx="17" cy="8" r="3" />
<path d="M2 20a5 5 0 0 1 10 0M12 20a5 5 0 0 1 10 0" />
</svg>
),
mail: (
<svg {...ICON_PROPS}>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
),
welcome: (
<svg {...ICON_PROPS}>
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7M16 19l2 2 4-4" />
</svg>
),
tenants: (
<svg {...ICON_PROPS}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<path d="M9 22V12h6v10" />
</svg>
),
modules: (
<svg {...ICON_PROPS}>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<path d="M3.27 6.96 12 12.01l8.73-5.05M12 22.08V12" />
</svg>
),
categories: (
<svg {...ICON_PROPS}>
<path d="M3 6h18M3 12h12M3 18h7" />
</svg>
),
grants: (
<svg {...ICON_PROPS}>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
),
directory: (
<svg {...ICON_PROPS}>
<path d="M12 2 2 7l10 5 10-5-10-5z" />
<path d="m2 17 10 5 10-5M2 12l10 5 10-5" />
</svg>
),
} as const;
function isActive(pathname: string, item: NavItem): boolean {
if (item.exact) return pathname === item.href;
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
export function ControlCenterNav() {
const t = useTranslations('controlCenter');
const pathname = usePathname();
const router = useRouter();
const role = useAuthStore((s) => s.user?.role);
const isAdmin = role === 'ADMIN' || role === 'SUPER_ADMIN';
const isSuperAdmin = role === 'SUPER_ADMIN';
const personal: NavGroup = {
id: 'personal',
label: '',
items: [
{ href: '/settings/general/account', label: t('items.account'), icon: ICONS.account },
{ href: '/settings/dashboard', label: t('items.widgets'), icon: ICONS.widgets, exact: true },
{ href: '/settings/dashboard/calendar', label: t('items.calendar'), icon: ICONS.calendar },
{ href: '/settings/custom-modules', label: t('items.myLinks'), icon: ICONS.link },
{ href: '/settings/general/desktop', label: t('items.desktop'), icon: ICONS.desktop },
],
};
const admin: NavGroup[] = [
{
id: 'people',
label: t('groups.people'),
items: [
{ href: '/admin/users', label: t('items.users'), icon: ICONS.users },
{ href: '/admin/groups', label: t('items.groups'), icon: ICONS.groups },
{ href: '/admin/welcome-mail', label: t('items.welcomeMail'), icon: ICONS.welcome },
{
href: '/admin/tenants',
label: t('items.tenants'),
icon: ICONS.tenants,
show: isSuperAdmin,
},
],
},
{
id: 'modules',
label: t('groups.modules'),
items: [
{ href: '/admin/modules', label: t('items.modules'), icon: ICONS.modules, exact: true },
{ href: '/admin/modules/grants', label: t('items.grants'), icon: ICONS.grants },
{ href: '/admin/modules/categories', label: t('items.categories'), icon: ICONS.categories },
{ href: '/admin/custom-modules', label: t('items.sharedLinks'), icon: ICONS.link },
],
},
{
id: 'connections',
label: t('groups.connections'),
items: [
{ href: '/admin/ldap', label: t('items.directory'), icon: ICONS.directory },
{ href: '/admin/smtp', label: t('items.mailServer'), icon: ICONS.mail },
],
},
];
const renderGroup = (group: NavGroup) => {
const items = group.items.filter((item) => item.show !== false);
if (items.length === 0) return null;
return (
<div key={group.id} className="cc-nav-group">
{group.label && <h3 className="cc-nav-heading">{group.label}</h3>}
<ul className="flex flex-col gap-px">
{items.map((item) => {
const active = isActive(pathname, item);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={`cc-nav-item nav-item ${active ? 'is-active' : ''}`}
>
<span className="cc-nav-icon">{item.icon}</span>
<span className="truncate">{item.label}</span>
</Link>
</li>
);
})}
</ul>
</div>
);
};
// Schmale Bildschirme: eine Auswahlliste statt der ganzen Liste, damit
// der Inhalt nicht erst nach zwanzig Eintraegen beginnt.
const blocks: Array<{ title: string; groups: NavGroup[] }> = [
{ title: t('settingsTitle'), groups: [personal] },
...(isAdmin ? [{ title: t('adminTitle'), groups: admin }] : []),
];
const allItems = blocks.flatMap((b) =>
b.groups.flatMap((g) => g.items.filter((item) => item.show !== false)),
);
const current = allItems.find((item) => isActive(pathname, item))?.href ?? '';
return (
<>
<div className="cc-nav-mobile">
<label htmlFor="cc-nav-select" className="sr-only">
{t('navLabel')}
</label>
<select
id="cc-nav-select"
value={current}
onChange={(e) => router.push(e.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground"
>
{blocks.flatMap((b) =>
b.groups.map((g) => (
<optgroup
key={`${b.title}-${g.id}`}
label={g.label ? `${b.title}: ${g.label}` : b.title}
>
{g.items
.filter((item) => item.show !== false)
.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</optgroup>
)),
)}
</select>
</div>
<nav aria-label={t('navLabel')} className="cc-nav">
<div className="cc-nav-block">
<h2 className="cc-nav-title">{t('settingsTitle')}</h2>
{renderGroup(personal)}
</div>
{isAdmin && (
<div className="cc-nav-block">
<h2 className="cc-nav-title">{t('adminTitle')}</h2>
{admin.map(renderGroup)}
</div>
)}
</nav>
</>
);
}
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
/**
* Einheitlicher Seitenkopf fuer Einstellungen und Verwaltung: Titel, ein
* Satz Erklaerung, rechts die Hauptaktion der Seite. Vorher hatte jede
* Seite ihre eigene Ueberschriftengroesse (mal text-lg, mal text-2xl).
*/
export function PageHeader({
title,
description,
actions,
children,
}: {
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
/** Zusatz unter der Beschreibung, z. B. ein Hinweis. */
children?: ReactNode;
}) {
return (
<header className="cc-page-header">
<div className="min-w-0 flex-1">
<h1 className="cc-page-title">{title}</h1>
{description && <p className="cc-page-description">{description}</p>}
{children}
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</header>
);
}
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
/**
* Ein Abschnitt einer Einstellungsseite: Karte mit Titel, kurzer
* Erklaerung, Inhalt und optional einer Fusszeile fuer die Schaltflaechen
* („Speichern“ immer unten rechts in derselben Karte).
*/
export function SettingsSection({
title,
description,
actions,
footer,
children,
id,
flush = false,
}: {
title?: ReactNode;
description?: ReactNode;
/** Kleine Aktion rechts im Kopf, z. B. „Bearbeiten“. */
actions?: ReactNode;
footer?: ReactNode;
children: ReactNode;
id?: string;
/** Inhalt ohne Innenabstand (fuer Tabellen und Listen bis an den Rand). */
flush?: boolean;
}) {
const headingId = id ? `${id}-title` : undefined;
return (
<section className="cc-section" aria-labelledby={title ? headingId : undefined} id={id}>
{(title || actions) && (
<div className="cc-section-head">
<div className="min-w-0 flex-1">
{title && (
<h2 id={headingId} className="cc-section-title">
{title}
</h2>
)}
{description && <p className="cc-section-description">{description}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
)}
<div className={flush ? 'cc-section-body-flush' : 'cc-section-body'}>{children}</div>
{footer && <div className="cc-section-footer">{footer}</div>}
</section>
);
}
@@ -6,6 +6,8 @@ import { useCallback, useEffect, useState } from 'react';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useCategoryLabel } from '@/lib/use-category-label';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { CustomModuleFormModal } from './custom-module-form-modal';
import { DeleteCustomModuleDialog } from './delete-custom-module-dialog';
@@ -84,20 +86,19 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
{texts.title}
</h1>
<button type="button" onClick={openCreate} className="btn btn-primary">
{texts.create}
</button>
</div>
<p className="text-sm text-muted-foreground">{texts.description}</p>
<div>
<PageHeader
title={texts.title}
description={texts.description}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary">
{texts.create}
</button>
}
/>
{loadFailed && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{tManage('loadError')}
</div>
)}
@@ -105,30 +106,34 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
„keine Eintraege“ waere dann falsch. */}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
<button type="button" onClick={openCreate} className="btn btn-primary">
{texts.create}
</button>
</div>
<SettingsSection>
<div className="flex flex-col items-start gap-3">
<div>
<p className="text-sm font-medium text-foreground">{texts.noModules}</p>
<p className="mt-1 text-sm text-muted-foreground">{texts.noModulesBody}</p>
</div>
<button type="button" onClick={openCreate} className="btn btn-secondary">
{texts.create}
</button>
</div>
</SettingsSection>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<SettingsSection flush>
<table className="w-full table-fixed text-sm">
<thead>
<tr className="border-b border-border">
<th className="w-[28%] px-5 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-5 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('url')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-[18%] px-5 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('category')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="w-[10rem] px-5 pb-2 text-right text-xs font-medium text-muted-foreground">
{tManage('actions')}
</th>
</tr>
@@ -136,30 +141,32 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
<tbody className="divide-y divide-border">
{modules.map((mod) => (
<tr key={mod.id} className="transition-colors hover:bg-muted/30">
<td className="px-4 py-3 font-medium text-foreground">
<td className="truncate px-5 py-3 font-medium text-foreground">
<Link href={`/modules/custom/${mod.id}`} className="link">
{mod.name}
</Link>
</td>
<td className="max-w-[24rem] px-4 py-3 text-muted-foreground">
<td className="px-5 py-3 text-muted-foreground">
<span className="block truncate" title={mod.url}>
{mod.url}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{categoryLabel(mod.category)}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<td className="truncate px-5 py-3 text-muted-foreground">
{categoryLabel(mod.category)}
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => openEdit(mod)}
className="rounded px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
className="btn btn-subtle text-xs"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(mod)}
className="rounded px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
className="btn btn-subtle text-xs text-destructive"
>
{tCommon('delete')}
</button>
@@ -169,7 +176,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
))}
</tbody>
</table>
</div>
</SettingsSection>
)}
{showFormModal && (
@@ -1,5 +1,6 @@
'use client';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useState, useTransition, useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import {
@@ -149,12 +150,9 @@ export function AccountSettingsForm() {
const userInitial = username.charAt(0).toUpperCase() || '?';
return (
<div className="space-y-8 max-w-lg">
<div>
{/* ── Avatar section ─────────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-4">
{t('account.avatarLabel')}
</h2>
<SettingsSection id="account-avatar" title={t('account.avatarLabel')}>
<div className="flex items-center gap-4 mb-4">
<div className="relative h-16 w-16 shrink-0">
{hasAvatar ? (
@@ -197,7 +195,7 @@ export function AccountSettingsForm() {
disabled={isAvatarPending}
onChange={handleAvatarUpload}
/>
<span className="btn btn-primary">
<span className="btn btn-secondary">
{isAvatarPending ? '...' : t('account.uploadCta')}
</span>
</label>
@@ -206,20 +204,30 @@ export function AccountSettingsForm() {
type="button"
disabled={isAvatarPending}
onClick={handleDeleteAvatar}
className="inline-flex items-center justify-center rounded-md border border-destructive/40 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
className="btn btn-subtle text-destructive"
>
{t('account.deleteAvatarCta')}
</button>
)}
</div>
</section>
</SettingsSection>
{/* ── Accent color section ────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-1">
{t('account.accentColorLabel')}
</h2>
<p className="text-xs text-muted-foreground mb-4">{t('account.accentColorHelp')}</p>
<SettingsSection
id="account-accent"
title={t('account.accentColorLabel')}
description={t('account.accentColorHelp')}
footer={
<button
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending || !isHexValid}
className="btn btn-primary"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
}
>
<div className="flex items-center gap-3 mb-1">
<input
type="color"
@@ -277,21 +285,32 @@ export function AccountSettingsForm() {
{t('account.accentColorError')}
</div>
)}
<button
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending || !isHexValid}
className="btn btn-primary"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
</section>
</SettingsSection>
{/* ── Password section ────────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-4">
{t('account.passwordSectionTitle')}
</h2>
<SettingsSection
id="account-password"
title={t('account.passwordSectionTitle')}
footer={
isLocalUser === true ? (
<button
type="submit"
form="account-password-form"
disabled={isPwPending}
className="btn btn-primary"
>
{isPwPending ? (
<svg aria-hidden="true" className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
tAuth('changePassword.submit')
)}
</button>
) : undefined
}
>
{isLocalUser === false && (
<div className="rounded-md bg-muted px-4 py-3 text-sm text-muted-foreground">
@@ -317,7 +336,12 @@ export function AccountSettingsForm() {
</div>
)}
<form ref={pwFormRef} onSubmit={handlePasswordSubmit} className="space-y-4">
<form
id="account-password-form"
ref={pwFormRef}
onSubmit={handlePasswordSubmit}
className="max-w-md space-y-4"
>
<div className="space-y-2">
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground">
{tAuth('changePassword.currentPassword')}
@@ -362,24 +386,10 @@ export function AccountSettingsForm() {
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<button
type="submit"
disabled={isPwPending}
className="btn btn-primary"
>
{isPwPending ? (
<svg aria-hidden="true" className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
tAuth('changePassword.submit')
)}
</button>
</form>
</>
)}
</section>
</SettingsSection>
</div>
);
}
@@ -10,6 +10,7 @@ import {
testSource,
} from '@/lib/calendar-api';
import type { CalendarSource, CreateSourcePayload } from '@/lib/calendar-api';
import { SettingsSection } from '@/components/control-center/settings-section';
import { CalendarSourceForm } from './calendar-source-form';
/**
@@ -30,6 +31,7 @@ const TYPE_LABELS: Record<string, string> = {
export function CalendarSettingsPanel() {
const t = useTranslations('widgets');
const ts = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const [sources, setSources] = useState<CalendarSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -40,7 +42,9 @@ export function CalendarSettingsPanel() {
const [saveError, setSaveError] = useState<string | null>(null);
const [editSaveError, setEditSaveError] = useState<string | null>(null);
const [editSaveSuccess, setEditSaveSuccess] = useState(false);
const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>({});
const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>(
{},
);
// Load sources on mount
useEffect(() => {
@@ -53,55 +57,51 @@ export function CalendarSettingsPanel() {
}, []);
// Toggle visibility (CAL-02)
const handleVisibilityToggle = useCallback(
async (source: CalendarSource) => {
const newIsVisible = !source.isVisible;
// Optimistic update
const handleVisibilityToggle = useCallback(async (source: CalendarSource) => {
const newIsVisible = !source.isVisible;
// Optimistic update
setSources((prev) =>
prev.map((s) => (s.id === source.id ? { ...s, isVisible: newIsVisible } : s)),
);
try {
await updateSource(source.id, { isVisible: newIsVisible });
} catch {
// Revert on error
setSources((prev) =>
prev.map((s) =>
s.id === source.id ? { ...s, isVisible: newIsVisible } : s,
),
prev.map((s) => (s.id === source.id ? { ...s, isVisible: source.isVisible } : s)),
);
try {
await updateSource(source.id, { isVisible: newIsVisible });
} catch {
// Revert on error
setSources((prev) =>
prev.map((s) =>
s.id === source.id ? { ...s, isVisible: source.isVisible } : s,
),
);
}
},
[],
);
}
}, []);
// Add new source
const handleAddSource = useCallback(async (payload: CreateSourcePayload) => {
setIsSaving(true);
setSaveError(null);
try {
const created = await addSource(payload);
setSources((prev) => [...prev, created]);
setShowAddForm(false);
// Auto-test connection after save
setTestResults((prev) => ({ ...prev, [created.id]: 'testing' }));
const handleAddSource = useCallback(
async (payload: CreateSourcePayload) => {
setIsSaving(true);
setSaveError(null);
try {
const result = await testSource(created.id);
setTestResults((prev) => ({
...prev,
[created.id]: result.success ? 'success' : 'error',
}));
const created = await addSource(payload);
setSources((prev) => [...prev, created]);
setShowAddForm(false);
// Auto-test connection after save
setTestResults((prev) => ({ ...prev, [created.id]: 'testing' }));
try {
const result = await testSource(created.id);
setTestResults((prev) => ({
...prev,
[created.id]: result.success ? 'success' : 'error',
}));
} catch {
setTestResults((prev) => ({ ...prev, [created.id]: 'error' }));
}
} catch {
setTestResults((prev) => ({ ...prev, [created.id]: 'error' }));
setSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
} finally {
setIsSaving(false);
}
} catch {
setSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
} finally {
setIsSaving(false);
}
}, [t]);
},
[t],
);
// Delete source
const handleDeleteSource = useCallback(async (id: string) => {
@@ -116,205 +116,206 @@ export function CalendarSettingsPanel() {
// Loading state
if (isLoading) {
return (
<div className="text-sm text-muted-foreground">Loading...</div>
);
return <div className="text-sm text-muted-foreground">Loading...</div>;
}
return (
<div className="space-y-6">
<div>
{/* Source list */}
{sources.length === 0 && !showAddForm && (
<p className="text-sm text-muted-foreground">
{t('calendar.sourceEmpty')}
</p>
)}
<SettingsSection
flush
id="calendar-sources"
title={tCc('calendar.sourcesTitle')}
footer={
!showAddForm ? (
<button type="button" onClick={() => setShowAddForm(true)} className="btn btn-primary">
{t('calendar.addSource')}
</button>
) : undefined
}
>
{sources.length === 0 && (
<p className="px-5 pb-4 text-sm text-muted-foreground">{t('calendar.sourceEmpty')}</p>
)}
{sources.length > 0 && (
<div className="space-y-2">
{sources.map((source) => (
<div
key={source.id}
className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"
>
{/* Color dot */}
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: source.color || '#808080' }}
aria-hidden="true"
/>
{sources.length > 0 && (
<div className="divide-y divide-border border-t border-border">
{sources.map((source) => (
<div key={source.id} className="flex items-center gap-3 px-5 py-3">
{/* Color dot */}
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: source.color || '#808080' }}
aria-hidden="true"
/>
{/* Name */}
<span className="flex-1 text-sm font-medium text-foreground">
{source.name}
</span>
{/* Name */}
<span className="flex-1 text-sm font-medium text-foreground">{source.name}</span>
{/* Type badge */}
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{TYPE_LABELS[source.type] || source.type}
</span>
{/* Type badge */}
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{TYPE_LABELS[source.type] || source.type}
</span>
{/* Connection status indicator. role="img" (D-03): die
{/* Connection status indicator. role="img" (D-03): die
Beschriftung ist der EINZIGE Text dieser Symbole (das
<svg> darin ist bereits aria-hidden) — ohne Rolle ist ein
<span> "generic" und die Vorlesehilfe verwirft
aria-label stillschweigend. */}
{source.lastSyncError ? (
<span
title={source.lastSyncError}
className="text-orange-500"
role="img"
aria-label={t('calendar.syncErrorLabel')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{source.lastSyncError ? (
<span
title={source.lastSyncError}
className="text-orange-500"
role="img"
aria-label={t('calendar.syncErrorLabel')}
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
) : testResults[source.id] === 'success' ? (
<span
className="text-green-500"
role="img"
aria-label={t('calendar.connectionSuccess')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
) : testResults[source.id] === 'success' ? (
<span
className="text-green-500"
role="img"
aria-label={t('calendar.connectionSuccess')}
>
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
) : testResults[source.id] === 'error' ? (
<span
className="text-orange-500"
role="img"
aria-label={t('calendar.connectionFailedLabel')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
) : testResults[source.id] === 'error' ? (
<span
className="text-orange-500"
role="img"
aria-label={t('calendar.connectionFailedLabel')}
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</span>
) : null}
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</span>
) : null}
{/* Visibility toggle (CAL-02) */}
<button
type="button"
role="switch"
aria-checked={source.isVisible}
aria-label={`Toggle visibility for ${source.name}`}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
source.isVisible ? 'bg-primary' : 'bg-muted'
}`}
onClick={() => handleVisibilityToggle(source)}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ${
source.isVisible ? 'translate-x-5' : 'translate-x-0'
{/* Visibility toggle (CAL-02) */}
<button
type="button"
role="switch"
aria-checked={source.isVisible}
aria-label={`Toggle visibility for ${source.name}`}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
source.isVisible ? 'bg-primary' : 'bg-muted'
}`}
/>
</button>
{/* Edit button */}
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setEditingId(editingId === source.id ? null : source.id);
setEditSaveSuccess(false);
setEditSaveError(null);
}}
aria-label={`Edit ${source.name}`}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
onClick={() => handleVisibilityToggle(source)}
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ${
source.isVisible ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
{/* Delete button */}
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => setDeletingId(source.id)}
aria-label={`Delete ${source.name}`}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{/* Edit button */}
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setEditingId(editingId === source.id ? null : source.id);
setEditSaveSuccess(false);
setEditSaveError(null);
}}
aria-label={`Edit ${source.name}`}
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
))}
</div>
)}
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/* Edit form (below the source being edited) */}
{/* Delete button */}
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => setDeletingId(source.id)}
aria-label={`Delete ${source.name}`}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
))}
</div>
)}
</SettingsSection>
{/* Edit form (below the source list) */}
{editingId && (
<div className="rounded-md border border-border bg-card p-4">
{editSaveError && (
<p className="mb-3 text-sm text-destructive">{editSaveError}</p>
)}
<SettingsSection id="calendar-edit-source" title="Edit Source">
{editSaveError && <p className="mb-3 text-sm text-destructive">{editSaveError}</p>}
{editSaveSuccess && (
<p className="mb-3 text-sm text-green-600 dark:text-green-400">{t('calendar.saveSuccess') || 'Gespeichert'}</p>
<p className="mb-3 text-sm text-green-600 dark:text-green-400">
{t('calendar.saveSuccess') || 'Gespeichert'}
</p>
)}
<h3 className="mb-3 text-sm font-semibold text-foreground">
Edit Source
</h3>
<CalendarSourceForm
initialValues={(() => {
const s = sources.find((src) => src.id === editingId);
@@ -337,9 +338,7 @@ export function CalendarSettingsPanel() {
setEditSaveSuccess(false);
try {
const updated = await updateSource(editingId, payload);
setSources((prev) =>
prev.map((s) => (s.id === editingId ? updated : s)),
);
setSources((prev) => prev.map((s) => (s.id === editingId ? updated : s)));
setEditSaveSuccess(true);
} catch {
setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
@@ -350,35 +349,19 @@ export function CalendarSettingsPanel() {
onCancel={() => setEditingId(null)}
isSaving={isSaving}
/>
</div>
)}
{/* Add source button */}
{!showAddForm && (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="btn btn-primary"
>
{t('calendar.addSource')}
</button>
</SettingsSection>
)}
{/* Add source form */}
{showAddForm && (
<div className="rounded-md border border-border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">
{t('calendar.addSource')}
</h3>
{saveError && (
<p className="mb-3 text-sm text-destructive">{saveError}</p>
)}
<SettingsSection id="calendar-add-source" title={t('calendar.addSource')}>
{saveError && <p className="mb-3 text-sm text-destructive">{saveError}</p>}
<CalendarSourceForm
onSave={handleAddSource}
onCancel={() => setShowAddForm(false)}
isSaving={isSaving}
/>
</div>
</SettingsSection>
)}
{/* Delete confirmation dialog (UI-SPEC destructive pattern) */}
@@ -399,12 +382,8 @@ export function CalendarSettingsPanel() {
aria-modal="true"
aria-label={t('calendar.deleteDialogLabel')}
>
<h3 className="mb-2 text-lg font-semibold text-foreground">
{ts('sourceDeleteCta')}
</h3>
<p className="mb-4 text-sm text-muted-foreground">
{ts('sourceDeleteConfirm')}
</p>
<h3 className="mb-2 text-lg font-semibold text-foreground">{ts('sourceDeleteCta')}</h3>
<p className="mb-4 text-sm text-muted-foreground">{ts('sourceDeleteConfirm')}</p>
<div className="flex justify-end gap-3">
<button
type="button"
@@ -2,11 +2,15 @@
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { desktopDownloadUrl, type DesktopFileInfo, type DesktopLatestInfo, formatFileSize, loadDesktopLatest } from '@/lib/desktop';
import {
desktopDownloadUrl,
type DesktopFileInfo,
type DesktopLatestInfo,
formatFileSize,
loadDesktopLatest,
} from '@/lib/desktop';
import { useIsDesktopClient } from '@/lib/desktop-client';
const BUTTON_CLASS =
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90';
import { SettingsSection } from '@/components/control-center/settings-section';
function WindowsIcon() {
return (
@@ -21,7 +25,15 @@ function WindowsIcon() {
function LinuxIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l2.5 2L4 10" strokeLinecap="round" strokeLinejoin="round" />
<line x1="8" y1="10" x2="11" y2="10" strokeLinecap="round" />
@@ -36,6 +48,7 @@ function DownloadButton({
testId,
locale,
fileInfoText,
primary = false,
}: {
file: DesktopFileInfo;
label: string;
@@ -43,10 +56,17 @@ function DownloadButton({
testId: string;
locale: string;
fileInfoText: (values: { name: string; size: string }) => string;
/** Hauptknopf der Section (gelb); nur einer je Section. */
primary?: boolean;
}) {
return (
<div>
<a href={desktopDownloadUrl(file)} download data-testid={testId} className={BUTTON_CLASS}>
<div className="text-right">
<a
href={desktopDownloadUrl(file)}
download
data-testid={testId}
className={`btn ${primary ? 'btn-primary' : 'btn-secondary'}`}
>
{icon}
{label}
</a>
@@ -85,55 +105,73 @@ export function DesktopAppSettings() {
const fileInfoText = (values: { name: string; size: string }) => t('desktop.fileInfo', values);
const footer =
info && (info.files.windows || info.files.linux) ? (
<>
{info.files.linux && (
<DownloadButton
file={info.files.linux}
label={t('desktop.downloadLinux')}
icon={<LinuxIcon />}
testId="desktop-download-linux"
locale={locale}
fileInfoText={fileInfoText}
primary={!info.files.windows}
/>
)}
{info.files.windows && (
<DownloadButton
file={info.files.windows}
label={t('desktop.downloadWindows')}
icon={<WindowsIcon />}
testId="desktop-download-windows"
locale={locale}
fileInfoText={fileInfoText}
primary
/>
)}
</>
) : undefined;
return (
<div>
<p className="text-sm text-muted-foreground">{t('desktop.intro')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.firstStart')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.tray')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.update')}</p>
<SettingsSection footer={footer}>
<div className="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>{t('desktop.intro')}</p>
<p>{t('desktop.firstStart')}</p>
<p>{t('desktop.tray')}</p>
<p>{t('desktop.update')}</p>
</div>
{isDesktop && (
<div
data-testid="desktop-connected"
className="mt-4 space-y-1 rounded border border-border bg-muted/30 p-3 text-sm"
className="mt-4 max-w-prose space-y-1 rounded-md bg-muted px-4 py-3 text-sm"
>
<p className="text-foreground">{t('desktop.connectedTo', { origin: window.location.origin })}</p>
<p className="text-foreground">
{t('desktop.connectedTo', { origin: window.location.origin })}
</p>
<p className="text-muted-foreground">{t('desktop.changeHint')}</p>
</div>
)}
{info === null && <p className="mt-4 text-sm text-muted-foreground">{t('desktop.unavailable')}</p>}
{info === null && (
<p className="mt-4 border-t border-border pt-4 text-sm text-muted-foreground">
{t('desktop.unavailable')}
</p>
)}
{info && (
<>
<p className="mt-4 text-sm text-foreground">{t('desktop.versionLabel', { version: info.version })}</p>
<div className="mt-4 border-t border-border pt-4">
<p className="text-sm font-medium text-foreground">
{t('desktop.versionLabel', { version: info.version })}
</p>
{info.channel === 'beta' && (
<p className="text-sm text-muted-foreground">{t('desktop.channelBeta', { commit: info.commit })}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t('desktop.channelBeta', { commit: info.commit })}
</p>
)}
<div className="mt-4 flex flex-wrap gap-4">
{info.files.windows && (
<DownloadButton
file={info.files.windows}
label={t('desktop.downloadWindows')}
icon={<WindowsIcon />}
testId="desktop-download-windows"
locale={locale}
fileInfoText={fileInfoText}
/>
)}
{info.files.linux && (
<DownloadButton
file={info.files.linux}
label={t('desktop.downloadLinux')}
icon={<LinuxIcon />}
testId="desktop-download-linux"
locale={locale}
fileInfoText={fileInfoText}
/>
)}
</div>
</>
</div>
)}
</div>
</SettingsSection>
);
}
@@ -1,113 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
/**
* Settings sub-sidebar (D-19, D-20).
* Phase 05 categories: Dashboard > Widgets, Calendar.
* Active item uses sidebar-accent pattern with aria-current="page".
*/
export function SettingsSidebar() {
const t = useTranslations('settings');
const pathname = usePathname();
const items = [
{
label: t('categoryWidgets'),
href: '/settings/dashboard',
},
{
label: t('categoryCalendar'),
href: '/settings/dashboard/calendar',
},
];
const isActive = (href: string) => {
if (href === '/settings/dashboard') {
// Active when exactly on /settings/dashboard (not on sub-routes like /calendar)
return pathname === '/settings/dashboard';
}
return pathname.startsWith(href);
};
return (
// a11y/useSemanticElements: role="navigation" nicht auf das <aside> gehoben --
// darunter stehen zwei <nav>-Bereiche (Allgemein, Dashboard); ein drittes
// Navigations-Landmark auf dem Rahmen waere doppelt. Jedes <nav> traegt
// stattdessen die Beschriftung seiner eigenen Kategorie.
<aside className="flex w-full shrink-0 flex-col border-b border-border py-2 md:w-[220px] md:border-b-0">
{/* Allgemein category — above Dashboard (Surface C, 07-06) */}
<div className="px-5 pb-1 pt-3">
<h2 className="text-xs font-semibold text-muted-foreground">
{t('categoryGeneral')}
</h2>
</div>
<nav className="mb-2 flex flex-col gap-0.5 px-3" aria-label={t('categoryGeneral')}>
<Link
href="/settings/general/account"
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/general/account')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={
isActive('/settings/general/account') ? 'page' : undefined
}
>
{t('categoryAccount')}
</Link>
<Link
href="/settings/general/desktop"
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/general/desktop')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={
isActive('/settings/general/desktop') ? 'page' : undefined
}
>
{t('categoryDesktopApp')}
</Link>
<Link
href="/settings/custom-modules"
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/custom-modules')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={
isActive('/settings/custom-modules') ? 'page' : undefined
}
>
{t('categoryCustomModules')}
</Link>
</nav>
{/* Dashboard category — existing items unchanged */}
<div className="px-5 pb-1 pt-4">
<h2 className="text-xs font-semibold text-muted-foreground">
{t('categoryDashboard')}
</h2>
</div>
<nav className="flex flex-col gap-0.5 px-3" aria-label={t('categoryDashboard')}>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive(item.href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={isActive(item.href) ? 'page' : undefined}
>
{item.label}
</Link>
))}
</nav>
</aside>
);
}
@@ -1,5 +1,6 @@
'use client';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
@@ -51,6 +52,7 @@ type TestFeedback =
*/
export function SmtpSettingsForm() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [testTo, setTestTo] = useState('');
@@ -137,250 +139,253 @@ export function SmtpSettingsForm() {
};
const inputClass =
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground';
'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground';
const labelClass = 'mb-1 block text-sm text-foreground';
return (
<form onSubmit={handleSave} className="space-y-4">
{/* Host */}
<div>
<label htmlFor="smtp-host" className={labelClass}>
{t('smtp.host')}
</label>
<input
id="smtp-host"
type="text"
required
placeholder="smtp.example.com"
className={inputClass}
value={form.host}
onChange={(e) =>
setForm((prev) => ({ ...prev, host: e.target.value }))
}
/>
</div>
{/* Port */}
<div>
<label htmlFor="smtp-port" className={labelClass}>
{t('smtp.port')}
</label>
<input
id="smtp-port"
type="number"
required
min={1}
max={65535}
className={inputClass}
value={form.port}
onChange={(e) =>
setForm((prev) => ({
...prev,
port: Number(e.target.value) || 587,
}))
}
/>
</div>
{/* Verschlüsselung */}
<div>
<label htmlFor="smtp-encryption" className={labelClass}>
{t('smtp.encryption')}
</label>
<select
id="smtp-encryption"
required
className={inputClass}
value={form.encryption}
onChange={(e) =>
setForm((prev) => ({
...prev,
encryption: e.target.value as FormState['encryption'],
}))
}
>
{ENCRYPTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Benutzername */}
<div>
<label htmlFor="smtp-username" className={labelClass}>
{t('smtp.username')}
</label>
<input
id="smtp-username"
type="text"
className={inputClass}
value={form.username}
onChange={(e) =>
setForm((prev) => ({ ...prev, username: e.target.value }))
}
/>
</div>
{/* Passwort + show/hide toggle */}
<div>
<label htmlFor="smtp-password" className={labelClass}>
{t('smtp.password')}
</label>
<div className="relative flex max-w-md">
<input
id="smtp-password"
type={showPassword ? 'text' : 'password'}
className="h-9 w-full rounded border border-border bg-background px-3 pr-10 text-sm text-foreground"
value={form.password}
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
/>
<button
type="button"
aria-label={
showPassword ? t('smtp.hidePassword') : t('smtp.showPassword')
}
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
/* eye-off icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
/* eye icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
<form onSubmit={handleSave}>
<SettingsSection id="smtp-server" title={tCc('smtp.server')} description={tCc('smtp.serverHelp')}>
<div className="grid max-w-2xl gap-4 sm:grid-cols-[minmax(0,1fr)_8rem]">
{/* Host */}
<div>
<label htmlFor="smtp-host" className={labelClass}>
{t('smtp.host')}
</label>
<input
id="smtp-host"
type="text"
required
placeholder="smtp.example.com"
className={inputClass}
value={form.host}
onChange={(e) =>
setForm((prev) => ({ ...prev, host: e.target.value }))
}
/>
</div>
{/* Port */}
<div>
<label htmlFor="smtp-port" className={labelClass}>
{t('smtp.port')}
</label>
<input
id="smtp-port"
type="number"
required
min={1}
max={65535}
className={inputClass}
value={form.port}
onChange={(e) =>
setForm((prev) => ({
...prev,
port: Number(e.target.value) || 587,
}))
}
/>
</div>
</div>
</div>
<div className="mt-4 max-w-2xl space-y-4">
{/* Verschlüsselung */}
<div>
<label htmlFor="smtp-encryption" className={labelClass}>
{t('smtp.encryption')}
</label>
<select
id="smtp-encryption"
required
className={inputClass}
value={form.encryption}
onChange={(e) =>
setForm((prev) => ({
...prev,
encryption: e.target.value as FormState['encryption'],
}))
}
>
{ENCRYPTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 grid max-w-2xl gap-4 sm:grid-cols-2">
{/* Benutzername */}
<div>
<label htmlFor="smtp-username" className={labelClass}>
{t('smtp.username')}
</label>
<input
id="smtp-username"
type="text"
className={inputClass}
value={form.username}
onChange={(e) =>
setForm((prev) => ({ ...prev, username: e.target.value }))
}
/>
</div>
{/* Passwort + show/hide toggle */}
<div>
<label htmlFor="smtp-password" className={labelClass}>
{t('smtp.password')}
</label>
<div className="relative flex">
<input
id="smtp-password"
type={showPassword ? 'text' : 'password'}
className="h-9 w-full rounded border border-border bg-background px-3 pr-10 text-sm text-foreground"
value={form.password}
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
/>
<button
type="button"
aria-label={
showPassword ? t('smtp.hidePassword') : t('smtp.showPassword')
}
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
/* eye-off icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
/* eye icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
</div>
</div>
</SettingsSection>
{/* Absenderadresse */}
<div>
<label htmlFor="smtp-from-address" className={labelClass}>
{t('smtp.fromAddress')}
</label>
<input
id="smtp-from-address"
type="email"
required
placeholder="tessera@example.com"
className={inputClass}
value={form.fromAddress}
onChange={(e) =>
setForm((prev) => ({ ...prev, fromAddress: e.target.value }))
}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('smtp.fromAddressHelp')}
</p>
</div>
<SettingsSection id="smtp-sender" title={tCc('smtp.sender')} description={tCc('smtp.senderHelp')}>
<div className="max-w-2xl space-y-4">
{/* Absenderadresse */}
<div>
<label htmlFor="smtp-from-address" className={labelClass}>
{t('smtp.fromAddress')}
</label>
<input
id="smtp-from-address"
type="email"
required
placeholder="tessera@example.com"
className={inputClass}
value={form.fromAddress}
onChange={(e) =>
setForm((prev) => ({ ...prev, fromAddress: e.target.value }))
}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('smtp.fromAddressHelp')}
</p>
</div>
{/* Fehlermeldungen an (quick-260914-m97) */}
<div>
<label htmlFor="smtp-bug-report-recipient" className={labelClass}>
{t('smtp.bugReportRecipient')}
</label>
<input
id="smtp-bug-report-recipient"
type="email"
placeholder="fehler@example.com"
className={inputClass}
value={form.bugReportRecipient}
onChange={(e) =>
setForm((prev) => ({ ...prev, bugReportRecipient: e.target.value }))
}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('smtp.bugReportRecipientHelp')}
</p>
</div>
</div>
</SettingsSection>
{/* Fehlermeldungen an (quick-260914-m97) */}
<div>
<label htmlFor="smtp-bug-report-recipient" className={labelClass}>
{t('smtp.bugReportRecipient')}
</label>
<input
id="smtp-bug-report-recipient"
type="email"
placeholder="fehler@example.com"
className={inputClass}
value={form.bugReportRecipient}
onChange={(e) =>
setForm((prev) => ({ ...prev, bugReportRecipient: e.target.value }))
}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('smtp.bugReportRecipientHelp')}
</p>
</div>
{/* Test-E-Mail Empfänger */}
<div>
<label htmlFor="smtp-test-to" className={labelClass}>
{t('smtp.testToLabel')}
</label>
<input
id="smtp-test-to"
type="email"
placeholder={t('smtp.testToPlaceholder')}
className={inputClass}
value={testTo}
onChange={(e) => setTestTo(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('smtp.testToHelp')}
</p>
</div>
{/* Action row */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={isSaving}
className="btn btn-primary"
>
{t('smtp.save')}
</button>
<button
type="button"
disabled={isTesting}
onClick={handleTest}
className="btn btn-secondary"
>
{t('smtp.test')}
</button>
</div>
{/* Connection test feedback */}
{testFeedback && (
<p
className={`text-sm ${
testFeedback.type === 'success'
? ''
: testFeedback.type === 'error'
? 'text-destructive'
: 'text-muted-foreground'
}`}
style={
testFeedback.type === 'success'
? { color: 'oklch(0.40 0.15 148)' }
: undefined
}
>
{testFeedback.message}
</p>
)}
<SettingsSection
id="smtp-check"
title={tCc('smtp.check')}
description={tCc('smtp.checkHelp')}
footer={
<>
<button type="button" disabled={isTesting} onClick={handleTest} className="btn btn-secondary">
{t('smtp.test')}
</button>
<button type="submit" disabled={isSaving} className="btn btn-primary">
{t('smtp.save')}
</button>
</>
}
>
{/* Test-E-Mail Empfänger */}
<div className="max-w-2xl">
<label htmlFor="smtp-test-to" className={labelClass}>
{t('smtp.testToLabel')}
</label>
<input
id="smtp-test-to"
type="email"
placeholder={t('smtp.testToPlaceholder')}
className={inputClass}
value={testTo}
onChange={(e) => setTestTo(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('smtp.testToHelp')}
</p>
</div>
{/* Connection test feedback */}
{testFeedback && (
<p
className={`text-sm ${
testFeedback.type === 'success'
? ''
: testFeedback.type === 'error'
? 'text-destructive'
: 'text-muted-foreground'
}`}
style={
testFeedback.type === 'success'
? { color: 'oklch(0.40 0.15 148)' }
: undefined
}
>
{testFeedback.message}
</p>
)}
</SettingsSection>
</form>
);
}
@@ -3,6 +3,7 @@
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { SettingsSection } from '@/components/control-center/settings-section';
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
import { ProxmoxWidgetConfigForm } from '@/components/settings/proxmox-widget-config-form';
@@ -53,10 +54,7 @@ interface WidgetSettingsPanelProps {
* Lists all placed widget instances grouped by type.
* Each instance is expandable to its type-specific config form.
*/
export function WidgetSettingsPanel({
widgets,
onWidgetUpdate,
}: WidgetSettingsPanelProps) {
export function WidgetSettingsPanel({ widgets, onWidgetUpdate }: WidgetSettingsPanelProps) {
const t = useTranslations('widgets');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
@@ -88,36 +86,35 @@ export function WidgetSettingsPanel({
);
return (
<div className="space-y-6">
<div>
{Object.entries(groupedByType).map(([type, instances]) => {
const def = WIDGET_REGISTRY[type as WidgetType];
if (!def) return null;
return (
<div key={type}>
{/* Type group heading */}
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<def.icon className="h-4 w-4 text-muted-foreground" />
{t(def.nameKey)}
</h2>
<div className="space-y-2">
<SettingsSection
key={type}
id={`widget-type-${type}`}
flush
title={
<span className="flex items-center gap-2">
<def.icon className="h-4 w-4 text-muted-foreground" />
{t(def.nameKey)}
</span>
}
>
<div className="divide-y divide-border overflow-hidden rounded-b-[calc(var(--radius)+2px)] border-t border-border">
{instances.map((widget, idx) => {
const isExpanded = expandedId === widget.id;
const isSaving = saving === widget.id;
return (
<div
key={widget.id}
className="rounded-md border border-border bg-card"
>
<div key={widget.id}>
{/* Instance header (click to expand) */}
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm text-foreground hover:bg-muted/50 transition-colors"
onClick={() =>
setExpandedId(isExpanded ? null : widget.id)
}
className="flex w-full items-center justify-between px-5 py-3 text-left text-sm text-foreground hover:bg-muted/50 transition-colors"
onClick={() => setExpandedId(isExpanded ? null : widget.id)}
>
<span>
{t(def.nameKey)} #{idx + 1}
@@ -163,35 +160,27 @@ export function WidgetSettingsPanel({
{/* Expanded config form */}
{isExpanded && (
<div className="border-t border-border px-4 py-4">
<div className="border-t border-border bg-muted/20 px-5 py-4">
{isSaving && (
<p className="mb-2 text-xs text-muted-foreground">
Saving...
</p>
<p className="mb-2 text-xs text-muted-foreground">Saving...</p>
)}
{/* Clock config (D-12, D-13) */}
{widget.widgetType === 'clock' && (
<ClockConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
{/* Search config (D-15) */}
{widget.widgetType === 'search' && (
<SearchProviderForm />
)}
{widget.widgetType === 'search' && <SearchProviderForm />}
{/* Note config (D-17) */}
{widget.widgetType === 'note' && (
<NoteConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -199,9 +188,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'calendar' && (
<CalendarConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -209,9 +196,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'favorites' && (
<FavoritesConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -219,9 +204,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'picture-frame' && (
<PictureFrameConfigForm
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -229,9 +212,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'xframe' && (
<XframeConfigForm
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -239,9 +220,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'proxmox' && (
<ProxmoxWidgetConfigForm
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
</div>
@@ -250,7 +229,7 @@ export function WidgetSettingsPanel({
);
})}
</div>
</div>
</SettingsSection>
);
})}
</div>
@@ -303,10 +282,7 @@ function ClockConfig({
<div className="space-y-4">
{/* Timezone select (D-12) */}
<div>
<label
htmlFor="timezone-select"
className="mb-1 block text-sm text-foreground"
>
<label htmlFor="timezone-select" className="mb-1 block text-sm text-foreground">
Timezone
</label>
<select
@@ -449,10 +425,7 @@ function CalendarConfig({
{/* Hinweis auf die Kalenderquellenverwaltung */}
<p className="text-xs text-muted-foreground">
{t('calendar.configSourcesHint')}{' '}
<Link
href="/settings/dashboard/calendar"
className="link"
>
<Link href="/settings/dashboard/calendar" className="link">
{t('calendar.configSourcesLink')}
</Link>
</p>
@@ -502,10 +475,7 @@ function FavoritesConfig({
return (
<div>
<label
htmlFor="favorites-title"
className="mb-1 block text-sm text-foreground"
>
<label htmlFor="favorites-title" className="mb-1 block text-sm text-foreground">
{t('favorites.titleLabel')}
</label>
<input