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:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { GroupFormModal } from './components/GroupFormModal';
|
||||
import { GroupMembersModal } from './components/GroupMembersModal';
|
||||
@@ -30,6 +32,7 @@ export interface Group {
|
||||
export default function AdminGroupsPage() {
|
||||
const t = useTranslations('admin.groups');
|
||||
const tCommon = useTranslations('common');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
@@ -122,20 +125,19 @@ export default function AdminGroupsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
description={tCc('descriptions.groups')}
|
||||
actions={
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{t('create')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -143,35 +145,33 @@ export default function AdminGroupsPage() {
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">{t('noGroups')}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">{t('noGroupsBody')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
<SettingsSection>
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">{t('noGroups')}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">{t('noGroupsBody')}</p>
|
||||
<button type="button" onClick={openCreate} className="btn btn-secondary">
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<SettingsSection flush>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="px-4 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-4 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('ldapBinding')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('defaultGroup')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('memberCount')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
<th className="px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
|
||||
{t('actions')}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -179,10 +179,10 @@ export default function AdminGroupsPage() {
|
||||
<tbody className="divide-y divide-border">
|
||||
{groups.map((group) => (
|
||||
<tr key={group.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
<td className="px-4 py-2.5 font-medium text-foreground">
|
||||
<span title={group.name}>{group.internalName ?? group.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
group.ldapDn
|
||||
@@ -193,7 +193,7 @@ export default function AdminGroupsPage() {
|
||||
{group.ldapDn ? t('boundBadge') : t('manualBadge')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleDefault(group)}
|
||||
@@ -222,27 +222,27 @@ export default function AdminGroupsPage() {
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{group.memberCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<td className="px-4 py-2.5 text-muted-foreground">{group.memberCount}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(group)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-subtle h-7 px-2 text-xs font-medium"
|
||||
>
|
||||
{tCommon('edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMembersGroup(group)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-subtle h-7 px-2 text-xs font-medium"
|
||||
>
|
||||
{t('membersButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(group)}
|
||||
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
|
||||
className="btn btn-subtle h-7 px-2 text-xs font-medium text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
</button>
|
||||
@@ -252,7 +252,7 @@ export default function AdminGroupsPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{showFormModal && (
|
||||
|
||||
@@ -1,42 +1,9 @@
|
||||
'use client';
|
||||
import { ControlCenterLayout } from '@/components/control-center/control-center-layout';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AdminSidebar } from '@/components/admin/admin-sidebar';
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = useTranslations('settings');
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col md:flex-row">
|
||||
<AdminSidebar />
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<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="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
{t('backToDashboard')}
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
/**
|
||||
* Einstellungen und Verwaltung teilen sich einen Rahmen mit einer
|
||||
* gemeinsamen Navigation (siehe ControlCenterLayout).
|
||||
*/
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <ControlCenterLayout>{children}</ControlCenterLayout>;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,10 +151,9 @@ describe('Kategorien-Verwaltungsseite', () => {
|
||||
expect(
|
||||
within(card('procurement')).getByText('Außerdem 2 persönliche Einträge von Benutzern'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Zurück zu Module' })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/modules',
|
||||
);
|
||||
// Die Navigation links reicht — kein eigener Ruecksprung-Link mehr.
|
||||
expect(screen.queryByRole('link', { name: 'Zurück zu Module' })).toBeNull();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Kategorien' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sperrt Pfeil nach oben bei der ersten und nach unten bei der letzten Kategorie und beim Eintrag', async () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import {
|
||||
assignModuleCategory,
|
||||
createModuleCategory,
|
||||
@@ -40,8 +41,7 @@ function Arrow({ direction }: { direction: 'up' | 'down' }) {
|
||||
);
|
||||
}
|
||||
|
||||
const arrowButtonClass =
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40';
|
||||
const arrowButtonClass = 'btn btn-subtle btn-icon disabled:opacity-40';
|
||||
|
||||
/** Vertauscht zwei Nachbarn einer Liste (Pfeil nach oben/unten). */
|
||||
function swapped<T>(list: T[], index: number, direction: -1 | 1): T[] {
|
||||
@@ -100,7 +100,7 @@ function DeleteCategoryDialog({
|
||||
aria-labelledby="delete-category-title"
|
||||
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
|
||||
>
|
||||
<h2 id="delete-category-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||
<h2 id="delete-category-title" className="cc-section-title mb-4">
|
||||
{t('deleteDialog.title')}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-foreground">
|
||||
@@ -164,6 +164,7 @@ function DeleteCategoryDialog({
|
||||
export default function AdminModuleCategoriesPage() {
|
||||
const t = useTranslations('adminModules.categories');
|
||||
const tCommon = useTranslations('common');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
|
||||
@@ -277,85 +278,63 @@ export default function AdminModuleCategoriesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link href="/admin/modules" className="text-sm font-medium link">
|
||||
{t('backToModules')}
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<PageHeader title={t('title')} description={tCc('descriptions.categories')} />
|
||||
|
||||
{(loadError || actionError) && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{actionError ?? t('loadError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="new-category-name" className="text-sm font-medium text-foreground">
|
||||
{t('createLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="new-category-name"
|
||||
type="text"
|
||||
maxLength={60}
|
||||
value={newName}
|
||||
placeholder={t('createPlaceholder')}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="flex h-10 w-72 max-w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={busy || newName.trim() === ''} className="btn btn-primary">
|
||||
{t('createButton')}
|
||||
</button>
|
||||
</form>
|
||||
<div className="space-y-4">
|
||||
<SettingsSection>
|
||||
<form onSubmit={handleCreate} className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<label htmlFor="new-category-name" className="text-sm font-medium text-foreground">
|
||||
{t('createLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="new-category-name"
|
||||
type="text"
|
||||
maxLength={60}
|
||||
value={newName}
|
||||
placeholder={t('createPlaceholder')}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || newName.trim() === ''}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('createButton')}
|
||||
</button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : (
|
||||
<ul className="grid gap-4">
|
||||
{overview.map((category, index) => {
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : (
|
||||
overview.map((category, index) => {
|
||||
const label = labelOf(category);
|
||||
const renaming = renamingKey === category.key;
|
||||
return (
|
||||
<li
|
||||
key={category.key}
|
||||
data-testid={`category-${category.key}`}
|
||||
className="rounded-lg border border-border bg-card p-4"
|
||||
>
|
||||
<section aria-label={label}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('moveUp')}
|
||||
title={t('moveUp')}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => moveCategory(index, -1)}
|
||||
className={arrowButtonClass}
|
||||
>
|
||||
<Arrow direction="up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('moveDown')}
|
||||
title={t('moveDown')}
|
||||
disabled={busy || index === overview.length - 1}
|
||||
onClick={() => moveCategory(index, 1)}
|
||||
className={arrowButtonClass}
|
||||
>
|
||||
<Arrow direction="down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renaming ? (
|
||||
// Huelle nur fuer den Testanker; Abstand kommt von space-y-4.
|
||||
<div key={category.key} data-testid={`category-${category.key}`}>
|
||||
<SettingsSection
|
||||
id={`category-section-${category.key}`}
|
||||
title={label}
|
||||
description={category.isSystem ? t('deleteBlocked') : undefined}
|
||||
flush
|
||||
actions={
|
||||
renaming ? (
|
||||
<form
|
||||
className="flex flex-1 flex-wrap items-center gap-2"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveRename(category);
|
||||
@@ -367,15 +346,8 @@ export default function AdminModuleCategoriesPage() {
|
||||
maxLength={60}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
className="flex h-9 w-64 max-w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
className="flex h-8 w-56 max-w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || renameValue.trim() === ''}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('renameSave')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenamingKey(null)}
|
||||
@@ -383,15 +355,41 @@ export default function AdminModuleCategoriesPage() {
|
||||
>
|
||||
{t('renameCancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || renameValue.trim() === ''}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('renameSave')}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="flex-1 text-base font-semibold text-foreground">{label}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('moveUp')}
|
||||
title={t('moveUp')}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => moveCategory(index, -1)}
|
||||
className={arrowButtonClass}
|
||||
>
|
||||
<Arrow direction="up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('moveDown')}
|
||||
title={t('moveDown')}
|
||||
disabled={busy || index === overview.length - 1}
|
||||
onClick={() => moveCategory(index, 1)}
|
||||
className={arrowButtonClass}
|
||||
>
|
||||
<Arrow direction="down" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => startRename(category)}
|
||||
className="btn btn-secondary"
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
{t('rename')}
|
||||
</button>
|
||||
@@ -400,25 +398,22 @@ export default function AdminModuleCategoriesPage() {
|
||||
disabled={busy || category.isSystem}
|
||||
title={category.isSystem ? t('deleteBlocked') : undefined}
|
||||
onClick={() => setDeleteTarget(category)}
|
||||
className="btn btn-secondary"
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{category.isSystem && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('deleteBlocked')}</p>
|
||||
)}
|
||||
|
||||
)
|
||||
}
|
||||
>
|
||||
{category.items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{t('empty')}</p>
|
||||
<p className="px-5 pb-4 text-sm text-muted-foreground">{t('empty')}</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-border rounded-md border border-border">
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{category.items.map((item, itemIndex) => (
|
||||
<li
|
||||
key={`${item.type}:${item.id}`}
|
||||
className="flex flex-wrap items-center gap-2 px-3 py-2"
|
||||
className="flex flex-wrap items-center gap-2 px-5 py-2"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
@@ -456,7 +451,7 @@ export default function AdminModuleCategoriesPage() {
|
||||
value={category.key}
|
||||
disabled={busy}
|
||||
onChange={(e) => assign(item, e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
{overview.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
@@ -470,16 +465,16 @@ export default function AdminModuleCategoriesPage() {
|
||||
)}
|
||||
|
||||
{category.personalCount > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="border-t border-border px-5 py-2 text-xs text-muted-foreground">
|
||||
{t('personalNote', { count: category.personalCount })}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</li>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteCategoryDialog
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
@@ -48,6 +50,7 @@ export default function AdminModuleGrantsPage() {
|
||||
const t = useTranslations('adminModules.grants');
|
||||
const tGrantLabel = useTranslations('admin.groups.grants');
|
||||
const tCommon = useTranslations('common');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
@@ -231,13 +234,11 @@ export default function AdminModuleGrantsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<PageHeader title={t('title')} description={tCc('descriptions.grants')} />
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -245,31 +246,44 @@ export default function AdminModuleGrantsPage() {
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : modules.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-card p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
|
||||
<Link
|
||||
href="/admin/modules"
|
||||
className="mt-2 inline-block text-sm font-medium link"
|
||||
>
|
||||
{t('emptyModulesLink')}
|
||||
</Link>
|
||||
</div>
|
||||
<SettingsSection>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
|
||||
<Link
|
||||
href="/admin/modules"
|
||||
className="mt-2 inline-block text-sm font-medium link"
|
||||
>
|
||||
{t('emptyModulesLink')}
|
||||
</Link>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
/>
|
||||
<SettingsSection
|
||||
flush
|
||||
footer={
|
||||
<div className="mr-auto space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="px-5 pb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
aria-label={t('searchPlaceholder')}
|
||||
className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{noMatch ? (
|
||||
<div className="rounded-md border border-border bg-card p-6 text-center">
|
||||
<div className="border-t border-border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t('noSearchResults', { search })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] rounded-md border border-border">
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] border-t border-border">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -360,10 +374,7 @@ export default function AdminModuleGrantsPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
|
||||
</>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { ActivateModuleDialog } from './components/ActivateModuleDialog';
|
||||
|
||||
@@ -30,6 +32,7 @@ interface TenantActivation {
|
||||
export default function AdminModulesPage() {
|
||||
const t = useTranslations('adminModules');
|
||||
const tCommon = useTranslations('common');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
// Shared signal that makes the sidebar refetch its active-module list
|
||||
@@ -49,6 +52,9 @@ export default function AdminModulesPage() {
|
||||
const [activateDialogModule, setActivateDialogModule] = useState<Module | null>(null);
|
||||
|
||||
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
// Nur zum Sortieren der Kategorie-Abschnitte; der Stand wird von der
|
||||
// Seitenleiste geladen (kein zusaetzlicher Abruf von dieser Seite).
|
||||
const storedCategories = useModuleCategoryStore((s) => s.categories);
|
||||
|
||||
const fetchModules = useCallback(async () => {
|
||||
try {
|
||||
@@ -119,6 +125,25 @@ export default function AdminModulesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Module je Kategorie (Kategorie steht schon in den Daten). Reihenfolge der
|
||||
// Abschnitte: gespeicherte Kategorien-Reihenfolge, sonst erstes Auftreten.
|
||||
const groupedModules = useMemo(() => {
|
||||
const groups = new Map<string, Module[]>();
|
||||
for (const mod of modules) {
|
||||
const list = groups.get(mod.category);
|
||||
if (list) list.push(mod);
|
||||
else groups.set(mod.category, [mod]);
|
||||
}
|
||||
const order = new Map(storedCategories.map((c, i) => [c.key, i]));
|
||||
return [...groups.entries()]
|
||||
.map(([category, mods]) => ({ category, mods }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(order.get(a.category) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(order.get(b.category) ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
}, [modules, storedCategories]);
|
||||
|
||||
const getDescription = (desc: Record<string, string> | string): string => {
|
||||
if (typeof desc === 'string') return desc;
|
||||
return desc.de || desc.en || Object.values(desc)[0] || '';
|
||||
@@ -133,30 +158,11 @@ export default function AdminModulesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/admin/modules/categories"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('categoriesLink')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/modules/grants"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('grantsLink')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<PageHeader title={t('title')} description={tCc('descriptions.modules')} />
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -166,76 +172,75 @@ export default function AdminModulesPage() {
|
||||
) : modules.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('noModules')}</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{modules.map((mod) => {
|
||||
const isActive = activations.has(mod.id);
|
||||
const isToggling = toggling === mod.id;
|
||||
groupedModules.map(({ category, mods }) => (
|
||||
<SettingsSection key={category} id={`module-category-${category}`} title={categoryLabel(category)} flush>
|
||||
<ul className="divide-y divide-border">
|
||||
{mods.map((mod) => {
|
||||
const isActive = activations.has(mod.id);
|
||||
const isToggling = toggling === mod.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mod.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-foreground">{mod.name}</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{mod.version}
|
||||
</span>
|
||||
{mod.isSystem && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{t('system')}
|
||||
return (
|
||||
<li key={mod.id} className="flex items-center justify-between gap-4 px-5 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground">{mod.name}</h3>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
v{mod.version}
|
||||
</span>
|
||||
{mod.isSystem && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{t('system')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{getDescription(mod.description)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<span
|
||||
className={`w-20 text-right text-xs font-medium ${
|
||||
isActive
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isActive ? t('activated') : t('deactivated')}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{categoryLabel(mod.category)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground truncate">
|
||||
{getDescription(mod.description)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center gap-3">
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isActive
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isActive ? t('activated') : t('deactivated')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
// Deactivation is unchanged -- direct toggle, no dialog.
|
||||
toggleModule(mod.id, true);
|
||||
} else {
|
||||
// Activation opens the confirmation dialog instead of
|
||||
// activating directly (D-10).
|
||||
setActivateDialogModule(mod);
|
||||
}
|
||||
}}
|
||||
disabled={isToggling}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait disabled:opacity-50 ${
|
||||
isActive ? 'bg-primary' : 'bg-input'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={isActive}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200 ${
|
||||
isActive ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
// Deactivation is unchanged -- direct toggle, no dialog.
|
||||
toggleModule(mod.id, true);
|
||||
} else {
|
||||
// Activation opens the confirmation dialog instead of
|
||||
// activating directly (D-10).
|
||||
setActivateDialogModule(mod);
|
||||
}
|
||||
}}
|
||||
disabled={isToggling}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait disabled:opacity-50 ${
|
||||
isActive ? 'bg-primary' : 'bg-input'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={isActive}
|
||||
aria-label={mod.name}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200 ${
|
||||
isActive ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SettingsSection>
|
||||
))
|
||||
)}
|
||||
|
||||
{activateDialogModule && (
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { SmtpSettingsForm } from '@/components/settings/smtp-settings-form';
|
||||
|
||||
export default function AdminSmtpPage() {
|
||||
const t = useTranslations('settings');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||
{t('smtp.title')}
|
||||
</h1>
|
||||
<PageHeader title={t('smtp.title')} description={tCc('descriptions.smtp')} />
|
||||
<SmtpSettingsForm />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -26,6 +28,7 @@ interface TenantFormData {
|
||||
export default function AdminTenantsPage() {
|
||||
const t = useTranslations('admin.tenants');
|
||||
const tCommon = useTranslations('common');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
@@ -143,18 +146,16 @@ export default function AdminTenantsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
description={tCc('descriptions.tenants')}
|
||||
actions={
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{t('create')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tenants table */}
|
||||
{loading ? (
|
||||
@@ -162,23 +163,23 @@ export default function AdminTenantsPage() {
|
||||
) : tenants.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('noTenants')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<SettingsSection flush>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="px-4 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-4 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('slug')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('status')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('userCount')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
<th className="px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
|
||||
{t('actions')}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -189,46 +190,46 @@ export default function AdminTenantsPage() {
|
||||
key={tenant.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
<td className="px-4 py-2.5 font-medium text-foreground">
|
||||
{tenant.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">
|
||||
<td className="px-4 py-2.5 text-muted-foreground font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
tenant.isActive
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
|
||||
? 'bg-status-ok/12 text-status-ok-fg'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{tenant.isActive ? tCommon('active') : tCommon('inactive')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
<td className="px-4 py-2.5 text-muted-foreground">
|
||||
{tenant.userCount}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(tenant)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-subtle h-7 px-2 text-xs font-medium"
|
||||
>
|
||||
{tCommon('edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleActive(tenant)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
className="btn btn-subtle h-7 px-2 text-xs font-medium"
|
||||
>
|
||||
{tenant.isActive ? tCommon('inactive') : tCommon('active')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirm(tenant.id)}
|
||||
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
|
||||
className="btn btn-subtle h-7 px-2 text-xs font-medium text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{tCommon('delete')}
|
||||
</button>
|
||||
@@ -238,14 +239,14 @@ export default function AdminTenantsPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Create/Edit modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="text-lg font-bold text-foreground mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
{editingTenant ? t('edit') : t('create')}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { UserAccessModal } from './components/UserAccessModal';
|
||||
|
||||
@@ -62,6 +64,7 @@ export default function AdminUsersPage() {
|
||||
const tCommon = useTranslations('common');
|
||||
const tHeader = useTranslations('header');
|
||||
const tWelcome = useTranslations('admin.users.welcomeMail');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
const locale = useLocale();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
@@ -304,19 +307,21 @@ export default function AdminUsersPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{t('create')}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
description={tCc('descriptions.users')}
|
||||
actions={
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
{t('create')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{loadError}
|
||||
</div>
|
||||
@@ -325,7 +330,7 @@ export default function AdminUsersPage() {
|
||||
{welcomeSuccess && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
|
||||
className="mb-4 flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
|
||||
>
|
||||
<span>{welcomeSuccess}</span>
|
||||
<button
|
||||
@@ -339,72 +344,83 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users table */}
|
||||
{/* Users table: feste Spaltenbreiten, Nebeninfos als zweite Zeile —
|
||||
passt ohne waagerechtes Scrollen in die Inhaltsbreite (~64rem). */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : loadError ? null : users.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('noUsers')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<SettingsSection flush>
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('username')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('email')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="w-[15%] px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('displayName')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="w-28 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('role')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="w-36 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('status')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
<th className="w-32 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t('lastLogin')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
<th className="w-[9.5rem] px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
|
||||
{t('actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-foreground">{user.username}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.email ?? '–'}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.displayName ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<tr key={user.id} className="align-top hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="truncate font-medium text-foreground" title={user.username}>
|
||||
{user.username}
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={user.email ?? undefined}
|
||||
>
|
||||
{user.email ?? '–'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground">
|
||||
<div className="truncate" title={user.displayName ?? undefined}>
|
||||
{user.displayName ?? '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
|
||||
className={`inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
|
||||
>
|
||||
{tHeader(`role.${user.role}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-3 py-2.5">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
user.isActive
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
|
||||
? 'bg-status-ok/12 text-status-ok-fg'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? tCommon('active') : tCommon('inactive')}
|
||||
</span>
|
||||
{user.welcomeMailSentAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
<div className="mt-1 text-xs leading-4 text-muted-foreground">
|
||||
{tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
|
||||
<td className="px-3 py-2.5 text-xs leading-5 text-muted-foreground">
|
||||
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<td className="px-4 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{canSendWelcome(user) && (
|
||||
<button
|
||||
@@ -415,7 +431,7 @@ export default function AdminUsersPage() {
|
||||
welcomeBlockedReason(user) ??
|
||||
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button'))
|
||||
}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<ActionIcon kind="mail" />
|
||||
<span className="sr-only">
|
||||
@@ -427,7 +443,7 @@ export default function AdminUsersPage() {
|
||||
type="button"
|
||||
onClick={() => setDetailsUser(user)}
|
||||
title={t('grants.detailsButton')}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ActionIcon kind="details" />
|
||||
<span className="sr-only">{t('grants.detailsButton')}</span>
|
||||
@@ -437,7 +453,7 @@ export default function AdminUsersPage() {
|
||||
type="button"
|
||||
onClick={() => openEdit(user)}
|
||||
title={tCommon('edit')}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ActionIcon kind="edit" />
|
||||
<span className="sr-only">{tCommon('edit')}</span>
|
||||
@@ -452,7 +468,7 @@ export default function AdminUsersPage() {
|
||||
}}
|
||||
disabled={user.id === currentUser?.id}
|
||||
title={tCommon('delete')}
|
||||
className="rounded p-1.5 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
className="btn btn-subtle btn-icon text-destructive hover:bg-destructive/10 disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ActionIcon kind="delete" />
|
||||
<span className="sr-only">{tCommon('delete')}</span>
|
||||
@@ -464,14 +480,14 @@ export default function AdminUsersPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Create/Edit modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
|
||||
<h2 className="text-lg font-bold text-foreground mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
{editingUser ? t('edit') : t('create')}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -619,7 +635,7 @@ export default function AdminUsersPage() {
|
||||
aria-labelledby="welcome-mail-title"
|
||||
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"
|
||||
>
|
||||
<h2 id="welcome-mail-title" className="text-lg font-bold text-foreground mb-3">
|
||||
<h2 id="welcome-mail-title" className="text-lg font-semibold text-foreground mb-3">
|
||||
{tWelcome('dialogTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-panel';
|
||||
|
||||
/**
|
||||
@@ -9,12 +10,11 @@ import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-p
|
||||
*/
|
||||
export default function CalendarSettingsPage() {
|
||||
const t = useTranslations('settings');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||
{t('categoryCalendar')}
|
||||
</h1>
|
||||
<PageHeader title={t('categoryCalendar')} description={tCc('descriptions.calendar')} />
|
||||
<CalendarSettingsPanel />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel';
|
||||
import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
|
||||
|
||||
@@ -20,6 +21,7 @@ import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
|
||||
*/
|
||||
export default function WidgetSettingsPage() {
|
||||
const t = useTranslations('settings');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
const tCommon = useTranslations('common');
|
||||
const [widgets, setWidgets] = useState<
|
||||
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
|
||||
@@ -42,9 +44,7 @@ export default function WidgetSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||
{t('categoryWidgets')}
|
||||
</h1>
|
||||
<PageHeader title={t('categoryWidgets')} description={tCc('descriptions.widgets')} />
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { AccountSettingsForm } from '@/components/settings/account-settings-form';
|
||||
|
||||
/**
|
||||
@@ -9,12 +10,11 @@ import { AccountSettingsForm } from '@/components/settings/account-settings-form
|
||||
*/
|
||||
export default function AccountSettingsPage() {
|
||||
const t = useTranslations('settings');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||
{t('account.title')}
|
||||
</h1>
|
||||
<PageHeader title={t('account.title')} description={tCc('descriptions.account')} />
|
||||
<AccountSettingsForm />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader } from '@/components/control-center/page-header';
|
||||
import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
|
||||
|
||||
/**
|
||||
@@ -9,12 +10,11 @@ import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
|
||||
*/
|
||||
export default function DesktopSettingsPage() {
|
||||
const t = useTranslations('settings');
|
||||
const tCc = useTranslations('controlCenter');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||
{t('desktop.title')}
|
||||
</h1>
|
||||
<PageHeader title={t('desktop.title')} description={tCc('descriptions.desktop')} />
|
||||
<DesktopAppSettings />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,48 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SettingsSidebar } from '@/components/settings/settings-sidebar';
|
||||
import { ControlCenterLayout } from '@/components/control-center/control-center-layout';
|
||||
|
||||
/**
|
||||
* Settings nested layout (D-19, D-20).
|
||||
* Renders within the portal AppShell, adding its own sub-sidebar on the left.
|
||||
* Back-to-dashboard link at top of content area per UI-SPEC.
|
||||
* Einstellungen und Verwaltung teilen sich einen Rahmen mit einer
|
||||
* gemeinsamen Navigation (siehe ControlCenterLayout).
|
||||
*/
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = useTranslations('settings');
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col md:flex-row">
|
||||
<SettingsSidebar />
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
{/* Back to Dashboard link */}
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<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="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
{t('backToDashboard')}
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <ControlCenterLayout>{children}</ControlCenterLayout>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Settings index page — redirects to /settings/dashboard (the default category).
|
||||
* Settings index page — leitet auf Konto um (erster Eintrag der Navigation).
|
||||
*/
|
||||
export default function SettingsPage() {
|
||||
redirect('/settings/dashboard');
|
||||
redirect('/settings/general/account');
|
||||
}
|
||||
|
||||
@@ -426,6 +426,188 @@ body {
|
||||
background-color: var(--primary-strong);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Einstellungen und Verwaltung (Design-Runde „Verwaltung aufgeraeumt“)
|
||||
Eine Navigation fuer beide Bereiche, ein Seitenkopf, eine Abschnittskarte.
|
||||
Inhalt mit begrenzter Lesebreite statt ueber den ganzen Bildschirm.
|
||||
------------------------------------------------------------------------- */
|
||||
.cc-aside {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.cc-aside {
|
||||
width: 248px;
|
||||
border-bottom: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
.cc-nav-mobile {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.cc-nav-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.cc-nav {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
.cc-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 0.75rem 1.5rem;
|
||||
}
|
||||
.cc-nav-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.cc-nav-title {
|
||||
padding: 0 0.625rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.cc-nav-block + .cc-nav-block {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.cc-nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.cc-nav-heading {
|
||||
padding: 0 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.cc-nav-item {
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--sidebar-foreground);
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.cc-nav-item:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
.cc-nav-item.is-active {
|
||||
background: var(--sidebar-accent);
|
||||
color: var(--sidebar-accent-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cc-nav-item:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.cc-nav-icon {
|
||||
display: inline-flex;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.cc-nav-item.is-active .cc-nav-icon {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cc-content {
|
||||
max-width: 64rem;
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.cc-content {
|
||||
padding: 2rem 2.5rem 4rem;
|
||||
}
|
||||
}
|
||||
.cc-page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.cc-page-title {
|
||||
font-size: 1.375rem;
|
||||
line-height: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.cc-page-description {
|
||||
margin-top: 0.375rem;
|
||||
max-width: 42rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.375rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.cc-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cc-section + .cc-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.cc-section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem 0;
|
||||
}
|
||||
.cc-section-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.cc-section-description {
|
||||
margin-top: 0.25rem;
|
||||
max-width: 40rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.cc-section-body {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
.cc-section-body-flush {
|
||||
padding-top: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* Tabellen schrumpfen in schmalen Karten (Handy, kleines Fenster) nicht bis
|
||||
zur Unlesbarkeit, sondern lassen sich innerhalb der Karte seitlich
|
||||
schieben. Gemessen an der Kartenbreite, nicht am Bildschirm, weil zwei
|
||||
Seitenleisten davon schon fast 500 px belegen. */
|
||||
.cc-section-body-flush {
|
||||
container-type: inline-size;
|
||||
}
|
||||
@container (max-width: 50rem) {
|
||||
.cc-section-body-flush table {
|
||||
min-width: 50rem;
|
||||
}
|
||||
}
|
||||
.cc-section-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--well);
|
||||
border-radius: 0 0 calc(var(--radius) + 2px) calc(var(--radius) + 2px);
|
||||
}
|
||||
|
||||
/* Checkboxen und Optionsfelder in der Link-Farbe statt Browser-Blau bzw.
|
||||
Gelb (Gelb mit weissem Haken waere kaum lesbar). */
|
||||
input[type="checkbox"], input[type="radio"] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmelden",
|
||||
"admin": {
|
||||
"label": "Administrator",
|
||||
"label": "Verwaltung",
|
||||
"users": "Benutzer",
|
||||
"tenants": "Mandanten",
|
||||
"modules": "Module",
|
||||
@@ -210,7 +210,7 @@
|
||||
"providerDeleteCta": "Anbieter löschen",
|
||||
"cancel": "Abbrechen",
|
||||
"smtp": {
|
||||
"title": "SMTP-Konfiguration",
|
||||
"title": "E-Mail-Versand",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"encryption": "Verschlüsselung",
|
||||
@@ -531,7 +531,7 @@
|
||||
},
|
||||
"admin": {
|
||||
"users": {
|
||||
"title": "Benutzerverwaltung",
|
||||
"title": "Benutzer",
|
||||
"create": "Benutzer erstellen",
|
||||
"edit": "Benutzer bearbeiten",
|
||||
"delete": "Benutzer löschen",
|
||||
@@ -574,7 +574,7 @@
|
||||
"resend": "Erneut senden",
|
||||
"sentAt": "Willkommensmail gesendet am {date}",
|
||||
"noEmail": "Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.",
|
||||
"noSmtp": "Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).",
|
||||
"noSmtp": "Es ist noch kein SMTP-Server eingerichtet (Verwaltung → E-Mail-Versand).",
|
||||
"inactive": "Das Konto ist deaktiviert.",
|
||||
"dialogTitle": "Willkommensmail senden",
|
||||
"dialogText": "Tessera schickt eine Willkommensmail mit Adresse, Benutzername und Hinweisen zur ersten Anmeldung an:",
|
||||
@@ -598,7 +598,7 @@
|
||||
"cannotDeleteActive": "Mandanten mit aktiven Benutzern können nicht gelöscht werden"
|
||||
},
|
||||
"ldap": {
|
||||
"title": "LDAP-Konfiguration",
|
||||
"title": "Verzeichnis (LDAP)",
|
||||
"connectionTitle": "Verbindungseinstellungen",
|
||||
"serverUrl": "Server-URL",
|
||||
"baseDn": "Basis-DN",
|
||||
@@ -745,7 +745,7 @@
|
||||
}
|
||||
},
|
||||
"customModules": {
|
||||
"title": "Eigene Module",
|
||||
"title": "Eigene Module für alle",
|
||||
"description": "Nehmen Sie andere Webseiten als Einträge in die Seitenleiste auf. Diese gemeinsamen Einträge sehen alle Benutzer unter der gewählten Kategorie, ein Klick zeigt die Seite direkt in Tessera. Persönliche Einträge nur für Sie legen Sie unter „Einstellungen → Eigene Module“ an.",
|
||||
"create": "Gemeinsamen Eintrag anlegen",
|
||||
"noModules": "Noch keine gemeinsamen Einträge",
|
||||
@@ -853,7 +853,7 @@
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
"title": "Modulverwaltung",
|
||||
"title": "Module",
|
||||
"description": "Module aktivieren oder deaktivieren",
|
||||
"activate": "Aktivieren",
|
||||
"deactivate": "Deaktivieren",
|
||||
@@ -868,7 +868,7 @@
|
||||
"grantsLink": "Freigaben-Matrix",
|
||||
"categoriesLink": "Kategorien",
|
||||
"grants": {
|
||||
"title": "Freigaben-Matrix",
|
||||
"title": "Freigaben",
|
||||
"searchPlaceholder": "Module oder Gruppen durchsuchen...",
|
||||
"emptyModules": "Es sind noch keine Module aktiviert. Aktiviere zuerst ein Modul unter Module.",
|
||||
"emptyModulesLink": "Zu Module",
|
||||
@@ -940,7 +940,7 @@
|
||||
"close": "Schließen",
|
||||
"sent": "Vielen Dank, die Meldung wurde gesendet.",
|
||||
"errorNotConfigured": "Für Fehlermeldungen ist noch kein Postfach eingerichtet.",
|
||||
"errorNotConfiguredAdminHint": "Legen Sie die Adresse unter Administrator → SMTP im Feld „Fehlermeldungen an\" fest.",
|
||||
"errorNotConfiguredAdminHint": "Legen Sie die Adresse unter Verwaltung → E-Mail-Versand im Feld „Fehlermeldungen an\" fest.",
|
||||
"errorNotConfiguredAdminLink": "Zu den SMTP-Einstellungen",
|
||||
"errorTooMany": "Zu viele Meldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.",
|
||||
"errorTooLarge": "Das Bild ist zu groß. Bitte senden Sie die Meldung ohne Bildschirmfoto.",
|
||||
@@ -1985,5 +1985,67 @@
|
||||
"countsLabel": "Anzahl der Clouds je Ampelfarbe",
|
||||
"open": "Nextcloud-Status öffnen"
|
||||
}
|
||||
},
|
||||
"controlCenter": {
|
||||
"navLabel": "Einstellungen und Verwaltung",
|
||||
"settingsTitle": "Einstellungen",
|
||||
"adminTitle": "Verwaltung",
|
||||
"groups": {
|
||||
"people": "Benutzer und Zugang",
|
||||
"modules": "Module",
|
||||
"connections": "Anbindungen"
|
||||
},
|
||||
"items": {
|
||||
"account": "Konto",
|
||||
"widgets": "Widgets",
|
||||
"calendar": "Kalender",
|
||||
"myLinks": "Eigene Module",
|
||||
"desktop": "Desktop-App",
|
||||
"users": "Benutzer",
|
||||
"groups": "Gruppen",
|
||||
"welcomeMail": "Willkommensmail",
|
||||
"tenants": "Mandanten",
|
||||
"modules": "Module",
|
||||
"grants": "Freigaben",
|
||||
"categories": "Kategorien",
|
||||
"sharedLinks": "Eigene Module für alle",
|
||||
"directory": "Verzeichnis (LDAP)",
|
||||
"mailServer": "E-Mail-Versand (SMTP)"
|
||||
},
|
||||
"descriptions": {
|
||||
"account": "Profilbild, Akzentfarbe und Passwort Ihres Tessera-Kontos.",
|
||||
"widgets": "Inhalte und Darstellung der Widgets auf Ihrem Dashboard.",
|
||||
"calendar": "Kalender, deren Termine im Kalender-Widget erscheinen.",
|
||||
"desktop": "Tessera als eigenes Programm mit Symbol im Infobereich der Taskleiste.",
|
||||
"smtp": "Über diesen Server verschickt Tessera alle E-Mails, zum Beispiel Willkommensmails, Erinnerungen und Benachrichtigungen.",
|
||||
"users": "Konten, Rollen und Zugriff aller Personen, die Tessera nutzen.",
|
||||
"groups": "Gruppen bündeln Benutzer, damit Sie Module an viele auf einmal freigeben können.",
|
||||
"tenants": "Organisationen, die diese Tessera-Installation getrennt voneinander nutzen.",
|
||||
"modules": "Schalten Sie Module für alle ein oder aus. Wer ein Modul nutzen darf, legen Sie unter Freigaben fest.",
|
||||
"grants": "Legen Sie fest, welche Gruppen ein Modul benutzen oder verwalten dürfen.",
|
||||
"categories": "Legen Sie fest, wie die Module in der Seitenleiste und im Marktplatz gruppiert und sortiert werden. Die Einstellung gilt für alle Benutzer.",
|
||||
"ldap": "Tessera übernimmt Benutzer und Gruppen aus Ihrem Active Directory. Tessera liest dort nur, es ändert nichts."
|
||||
},
|
||||
"smtp": {
|
||||
"server": "Server",
|
||||
"serverHelp": "Adresse und Zugang des Mailservers, über den Tessera E-Mails verschickt.",
|
||||
"sender": "Absender und Empfänger",
|
||||
"senderHelp": "Unter welcher Adresse Tessera schreibt und wohin Fehlermeldungen gehen.",
|
||||
"check": "Prüfen und speichern",
|
||||
"checkHelp": "Testen Sie die Angaben, bevor Sie sie speichern. Der Test verwendet die Werte in diesem Formular."
|
||||
},
|
||||
"ldap": {
|
||||
"connectionHelp": "Wo Tessera Ihr Active Directory erreicht und mit welchem Konto es dort liest.",
|
||||
"fieldMappingHelp": "Welche Angabe aus dem Verzeichnis in welches Feld des Tessera-Kontos übernommen wird.",
|
||||
"schedule": "Zeitplan",
|
||||
"scheduleHelp": "Wie oft Tessera das Verzeichnis selbstständig abgleicht und ob die Anbindung eingeschaltet ist. 0 Minuten bedeutet: nur von Hand.",
|
||||
"syncHelp": "Gleicht Benutzer und Gruppen sofort mit dem Verzeichnis ab, ohne auf den Zeitplan zu warten."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"textsTitle": "Texte der Mail"
|
||||
},
|
||||
"calendar": {
|
||||
"sourcesTitle": "Kalenderquellen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
"providerDeleteCta": "Delete provider",
|
||||
"cancel": "Cancel",
|
||||
"smtp": {
|
||||
"title": "SMTP Configuration",
|
||||
"title": "Email delivery",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"encryption": "Encryption",
|
||||
@@ -531,7 +531,7 @@
|
||||
},
|
||||
"admin": {
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
"title": "Users",
|
||||
"create": "Create User",
|
||||
"edit": "Edit User",
|
||||
"delete": "Delete User",
|
||||
@@ -598,7 +598,7 @@
|
||||
"cannotDeleteActive": "Cannot delete tenants with active users"
|
||||
},
|
||||
"ldap": {
|
||||
"title": "LDAP configuration",
|
||||
"title": "Directory (LDAP)",
|
||||
"connectionTitle": "Connection settings",
|
||||
"serverUrl": "Server URL",
|
||||
"baseDn": "Base DN",
|
||||
@@ -745,7 +745,7 @@
|
||||
}
|
||||
},
|
||||
"customModules": {
|
||||
"title": "Custom modules",
|
||||
"title": "Custom modules for everyone",
|
||||
"description": "Add other websites as entries in the sidebar. All users see these shared entries under the chosen category, and a click shows the page right inside Tessera. Personal entries only you can see are added under “Settings → Custom modules”.",
|
||||
"create": "Add shared entry",
|
||||
"noModules": "No shared entries yet",
|
||||
@@ -853,7 +853,7 @@
|
||||
}
|
||||
},
|
||||
"adminModules": {
|
||||
"title": "Module Management",
|
||||
"title": "Modules",
|
||||
"description": "Activate or deactivate modules",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
@@ -868,7 +868,7 @@
|
||||
"grantsLink": "Grants Matrix",
|
||||
"categoriesLink": "Categories",
|
||||
"grants": {
|
||||
"title": "Grants Matrix",
|
||||
"title": "Permissions",
|
||||
"searchPlaceholder": "Search modules or groups...",
|
||||
"emptyModules": "No modules are activated yet. Activate a module under Modules first.",
|
||||
"emptyModulesLink": "Go to Modules",
|
||||
@@ -1985,5 +1985,67 @@
|
||||
"countsLabel": "Number of clouds per traffic-light color",
|
||||
"open": "Open Nextcloud status"
|
||||
}
|
||||
},
|
||||
"controlCenter": {
|
||||
"navLabel": "Settings and administration",
|
||||
"settingsTitle": "Settings",
|
||||
"adminTitle": "Administration",
|
||||
"groups": {
|
||||
"people": "Users and access",
|
||||
"modules": "Modules",
|
||||
"connections": "Connections"
|
||||
},
|
||||
"items": {
|
||||
"account": "Account",
|
||||
"widgets": "Widgets",
|
||||
"calendar": "Calendar",
|
||||
"myLinks": "Custom modules",
|
||||
"desktop": "Desktop app",
|
||||
"users": "Users",
|
||||
"groups": "Groups",
|
||||
"welcomeMail": "Welcome mail",
|
||||
"tenants": "Tenants",
|
||||
"modules": "Modules",
|
||||
"grants": "Permissions",
|
||||
"categories": "Categories",
|
||||
"sharedLinks": "Custom modules for everyone",
|
||||
"directory": "Directory (LDAP)",
|
||||
"mailServer": "Email delivery (SMTP)"
|
||||
},
|
||||
"descriptions": {
|
||||
"account": "Profile picture, accent colour and password of your Tessera account.",
|
||||
"widgets": "Content and appearance of the widgets on your dashboard.",
|
||||
"calendar": "Calendars whose events appear in the calendar widget.",
|
||||
"desktop": "Tessera as its own program with an icon in the system tray.",
|
||||
"smtp": "Tessera sends all emails through this server, for example welcome mails, reminders and notifications.",
|
||||
"users": "Accounts, roles and access for everyone who uses Tessera.",
|
||||
"groups": "Groups bundle users so you can grant modules to many people at once.",
|
||||
"tenants": "Organisations that use this Tessera installation separately from one another.",
|
||||
"modules": "Turn modules on or off for everyone. Who may use a module is set under Permissions.",
|
||||
"grants": "Choose which groups may use or manage each module.",
|
||||
"categories": "Decide how modules are grouped and sorted in the sidebar and in the marketplace. The setting applies to all users.",
|
||||
"ldap": "Tessera takes over users and groups from your Active Directory. Tessera only reads there and never changes anything."
|
||||
},
|
||||
"smtp": {
|
||||
"server": "Server",
|
||||
"serverHelp": "Address and login of the outgoing mail server Tessera sends through.",
|
||||
"sender": "Sender and recipients",
|
||||
"senderHelp": "Which address Tessera writes from and where bug reports go.",
|
||||
"check": "Check and save",
|
||||
"checkHelp": "Test the settings before saving them. The test uses the values in this form."
|
||||
},
|
||||
"ldap": {
|
||||
"connectionHelp": "Where Tessera reaches your Active Directory and which account it uses to read it.",
|
||||
"fieldMappingHelp": "Which directory entry is copied into which field of the Tessera account.",
|
||||
"schedule": "Schedule",
|
||||
"scheduleHelp": "How often Tessera syncs with the directory on its own, and whether the connection is switched on. 0 minutes means manual only.",
|
||||
"syncHelp": "Syncs users and groups with the directory right away, without waiting for the schedule."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"textsTitle": "Mail texts"
|
||||
},
|
||||
"calendar": {
|
||||
"sourcesTitle": "Calendar sources"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user