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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user