feat(260928-ujj): Design Mosaik uebernehmen

- Bringt den Resize-Achsen-Fallback mit, damit sich Widgets auch bei leicht wackelnder Maus schmaler ziehen lassen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:04:52 +02:00
115 changed files with 2831 additions and 968 deletions
+95 -51
View File
@@ -5,14 +5,13 @@ import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { login } from '@/lib/auth-actions';
import { sanitizeNextPath } from '@/lib/safe-next';
import { BRAND_YELLOW } from '@/components/brand/brand';
import { TesseraLogo } from '@/components/brand/tessera-logo';
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
/**
* Split-screen login page (D-01).
* Left: Tessera branding on the brand yellow background (BRAND_YELLOW).
* Right: Login form on neutral background.
* Split-screen login page (D-01), Design „Mosaik“.
* Left (ab lg): dunkles Markenpanel mit Bildmarke, Claim und Farbmosaik.
* Right: Anmeldekarte auf der Arbeitsflaeche; unter lg nur die Karte.
* No sidebar or header (D-04, handled by (auth) layout).
*/
export default function LoginPage() {
@@ -42,41 +41,52 @@ export default function LoginPage() {
}
return (
<div className="flex min-h-screen">
{/* Left: Branding panel (hidden on mobile, visible on md+) */}
<div
className="hidden md:flex md:flex-1 items-center justify-center"
style={{ backgroundColor: BRAND_YELLOW }}
>
<div className="px-8 text-gray-900">
<TesseraLogo
variant="horizontal"
size={104}
plateOutline={false}
tagline={t('branding.tagline')}
className="gap-6"
wordmarkClassName="text-5xl"
taglineClassName="text-lg text-gray-800 max-w-xs"
/>
<div className="flex min-h-screen bg-background">
{/* Links (ab lg): dunkles Markenpanel im Ton der App-Leiste mit
grosser Bildmarke, Produktname, einem ruhigen Satz und einem
stillen Mosaik in Grau und Gelb — kein Verlauf, keine Animation. */}
<div className="relative hidden w-[45%] shrink-0 flex-col overflow-hidden bg-appbar px-14 py-12 text-appbar-foreground lg:flex">
<TesseraLogo
variant="horizontal"
size={40}
plateOutline="always"
className="gap-3"
wordmarkClassName="text-xl font-semibold tracking-normal text-appbar-foreground"
/>
<div className="my-auto max-w-[30rem]">
<LoginMosaic />
<p className="mt-12 text-[32px] font-semibold leading-10 tracking-tight">
{t('branding.claim')}
</p>
<p className="mt-3 text-base leading-6 text-appbar-muted">
{t('branding.tagline')}
</p>
</div>
</div>
{/* Right: Login form */}
<div className="flex flex-1 items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-sm space-y-8">
{/* Mobile-only branding */}
<div className="text-center md:hidden">
<TesseraLogo variant="horizontal" size={32} />
<p className="mt-2 text-sm text-muted-foreground">
{t('branding.tagline')}
</p>
{/* Rechts: Anmeldekarte zentriert auf der Arbeitsflaeche */}
<div className="flex flex-1 items-center justify-center px-4 py-10">
<div className="w-full max-w-[400px]">
{/* Unter lg: Bildmarke ueber der Karte */}
<div className="mb-6 flex justify-center lg:hidden">
<TesseraLogo
variant="horizontal"
size={32}
className="gap-2.5"
wordmarkClassName="text-lg font-semibold tracking-normal"
/>
</div>
<div className="surface space-y-6 p-6 sm:p-8">
{/* Login heading */}
<div className="text-center">
<h2 className="text-2xl font-bold text-foreground">
<div>
<h1 className="text-2xl font-semibold text-foreground">
{t('login')}
</h2>
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t('loginSubtitle')}
</p>
</div>
{/* Error message */}
@@ -87,12 +97,12 @@ export default function LoginPage() {
)}
{/* Login form */}
<form onSubmit={handleSubmit} className="space-y-5">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<div className="space-y-2">
<div className="space-y-1.5">
<label
htmlFor="username"
className="text-sm font-medium text-foreground"
className="block text-sm font-semibold text-foreground"
>
{t('username')}
</label>
@@ -102,16 +112,15 @@ export default function LoginPage() {
type="text"
required
autoComplete="username"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder={t('username')}
className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
/>
</div>
{/* Password */}
<div className="space-y-2">
<div className="space-y-1.5">
<label
htmlFor="password"
className="text-sm font-medium text-foreground"
className="block text-sm font-semibold text-foreground"
>
{t('password')}
</label>
@@ -121,33 +130,30 @@ export default function LoginPage() {
type="password"
required
autoComplete="current-password"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder={t('password')}
className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
/>
</div>
{/* Remember me (D-02) */}
<div className="flex items-center gap-2">
{/* Angemeldet bleiben (D-02) und Passwort vergessen (D-03) in einer Zeile */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<input
id="rememberMe"
name="rememberMe"
type="checkbox"
defaultChecked
className="h-4 w-4 rounded border-input text-primary focus:ring-ring"
className="h-4 w-4 rounded border-input"
/>
<label
htmlFor="rememberMe"
className="text-sm text-muted-foreground"
className="whitespace-nowrap text-sm text-muted-foreground"
>
{t('rememberMe')}
</label>
</div>
{/* Forgot password link (D-03) */}
<div className="flex justify-end">
<Link
href="/reset-password"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="whitespace-nowrap text-sm link"
>
{t('forgotPassword')}
</Link>
@@ -157,7 +163,7 @@ export default function LoginPage() {
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary w-full"
>
{isPending ? (
<svg
@@ -186,11 +192,49 @@ export default function LoginPage() {
)}
</button>
</form>
</div>
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
<DesktopDownloadLinks />
<div className="mt-6">
<DesktopDownloadLinks />
</div>
</div>
</div>
</div>
);
}
/**
* Stilles Mosaik (Design „Mosaik“): acht Kacheln in einem lockeren
* 4 x 3-Raster in Grautoenen, eine davon in der Akzentfarbe und — wie die
* Signalkachel der Bildmarke — leicht gedreht. Rein dekorativ.
*/
function LoginMosaic() {
// Runde 2: nur Gelb und Graphit-/Grautoene des dunklen Panels.
const tiles: { col: number; row: number; color: string; rotate?: boolean }[] = [
{ col: 1, row: 1, color: 'oklch(1 0 0 / 0.14)' },
{ col: 2, row: 1, color: 'oklch(1 0 0 / 0.08)' },
{ col: 4, row: 1, color: 'oklch(1 0 0 / 0.1)' },
{ col: 2, row: 2, color: 'oklch(1 0 0 / 0.18)' },
{ col: 3, row: 2, color: 'var(--primary)', rotate: true },
{ col: 4, row: 2, color: 'oklch(1 0 0 / 0.06)' },
{ col: 1, row: 3, color: 'oklch(1 0 0 / 0.1)' },
{ col: 3, row: 3, color: 'oklch(1 0 0 / 0.05)' },
];
return (
<div aria-hidden="true" className="grid w-max grid-cols-4 grid-rows-3 gap-3">
{tiles.map((tile) => (
<span
key={`${tile.col}-${tile.row}`}
className="h-14 w-14 rounded-xl"
style={{
gridColumn: tile.col,
gridRow: tile.row,
backgroundColor: tile.color,
transform: tile.rotate ? 'rotate(12deg)' : undefined,
}}
/>
))}
</div>
);
}
@@ -83,7 +83,7 @@ export default function ResetPasswordTokenPage() {
{/* Heading */}
<div className="text-center">
<TesseraLogo variant="horizontal" size={32} />
<h2 className="mt-4 text-2xl font-bold text-foreground">
<h2 className="mt-4 text-2xl font-semibold text-foreground">
{t('resetPassword.newPasswordTitle')}
</h2>
</div>
@@ -99,7 +99,7 @@ export default function ResetPasswordTokenPage() {
</p>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary w-full"
>
{t('resetPassword.backToLogin')}
</Link>
@@ -164,7 +164,7 @@ export default function ResetPasswordTokenPage() {
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary w-full"
>
{isPending ? (
<svg
@@ -54,7 +54,7 @@ export default function ResetPasswordPage() {
{/* Heading */}
<div className="text-center">
<TesseraLogo variant="horizontal" size={32} />
<h2 className="mt-4 text-2xl font-bold text-foreground">
<h2 className="mt-4 text-2xl font-semibold text-foreground">
{t('resetPassword.title')}
</h2>
</div>
@@ -67,7 +67,7 @@ export default function ResetPasswordPage() {
</div>
<Link
href="/login"
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary w-full"
>
{t('resetPassword.backToLogin')}
</Link>
@@ -104,7 +104,7 @@ export default function ResetPasswordPage() {
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary w-full"
>
{isPending ? (
<svg
@@ -88,7 +88,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-4">{t('deleteConfirm.title')}</h2>
{loadingImpact ? (
@@ -116,7 +116,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
<button
type="button"
onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
@@ -77,7 +77,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<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-semibold text-foreground mb-4">
{group ? t('edit') : t('create')}
</h2>
@@ -101,7 +101,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
{!group && (
<p className="text-sm text-muted-foreground">
{t('createLdapHint')}{' '}
<Link href="/admin/ldap" className="text-primary hover:underline">
<Link href="/admin/ldap" className="link">
{t('goToLdap')}
</Link>
</p>
@@ -139,14 +139,14 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
<button
type="button"
onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="submit"
disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{saving ? tCommon('loading') : tCommon('save')}
</button>
@@ -158,7 +158,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<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-semibold text-foreground mb-4">
{t('members.title')} — {group.name}
</h2>
@@ -252,7 +252,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
<button
type="button"
onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('close')}
</button>
@@ -260,7 +260,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
type="button"
onClick={handleAdd}
disabled={adding || selectedUserIds.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{adding ? tCommon('loading') : `${t('members.addTitle')} (${selectedUserIds.length})`}
</button>
@@ -124,11 +124,11 @@ export default function AdminGroupsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{t('create')}
</button>
@@ -149,7 +149,7 @@ export default function AdminGroupsPage() {
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{t('create')}
</button>
+2 -2
View File
@@ -12,9 +12,9 @@ export default function AdminLayout({
const t = useTranslations('settings');
return (
<div className="flex h-full">
<div className="flex h-full flex-col md:flex-row">
<AdminSidebar />
<div className="flex-1 overflow-y-auto p-6">
<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"
+11 -11
View File
@@ -547,7 +547,7 @@ export default function AdminLdapPage() {
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
{/* Section 1: Connection Settings */}
<section className="rounded-lg border border-border p-6">
@@ -646,7 +646,7 @@ export default function AdminLdapPage() {
<button
type="submit"
disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{saving ? tCommon('loading') : t('save')}
</button>
@@ -654,7 +654,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleTestConnection}
disabled={!config && !formData.serverUrl}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{t('testConnection')}
</button>
@@ -816,7 +816,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleDiscoverGroups}
disabled={discovering}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{discovering ? tCommon('loading') : t('groupFilter.discover')}
</button>
@@ -919,7 +919,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleSaveGroupFilter}
disabled={savingFilter}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button>
@@ -941,7 +941,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleDiscoverGroupsToImport}
disabled={discoveringGroupImport}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{discoveringGroupImport
? tCommon('loading')
@@ -1015,7 +1015,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleImportGroups}
disabled={importingGroups || selectedImportDns.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{importingGroups
? tCommon('loading')
@@ -1142,7 +1142,7 @@ export default function AdminLdapPage() {
type="button"
onClick={() => handleImportUsers(selectedUserDns)}
disabled={importingUsers || selectedUserDns.length === 0}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{importingUsers
? tCommon('loading')
@@ -1234,7 +1234,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleSaveExcludeList}
disabled={savingExclude}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{savingExclude ? tCommon('loading') : t('userExclude.save')}
</button>
@@ -1297,7 +1297,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{t('save')}
</button>
@@ -1319,7 +1319,7 @@ export default function AdminLdapPage() {
type="button"
onClick={handleSync}
disabled={syncing}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{syncing ? t('sync.syncing') : t('sync.trigger')}
</button>
@@ -142,7 +142,7 @@ export function ActivateModuleDialog({
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="mb-2 break-words text-lg font-semibold text-foreground">
{t('title', { module: moduleName })}
</h2>
@@ -157,7 +157,7 @@ export function ActivateModuleDialog({
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{t('cancel')}
</button>
@@ -165,7 +165,7 @@ export function ActivateModuleDialog({
type="button"
onClick={handleConfigureLater}
disabled={isSubmitting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{t('configureLater')}
</button>
@@ -173,7 +173,7 @@ export function ActivateModuleDialog({
type="button"
onClick={handleGrantNow}
disabled={grantNowDisabled}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
className="btn btn-primary"
>
{t('grantNow')}
</button>
@@ -189,7 +189,7 @@ export default function AdminModuleGrantsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
{error && (
@@ -205,7 +205,7 @@ export default function AdminModuleGrantsPage() {
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link
href="/admin/modules"
className="mt-2 inline-block text-sm font-medium text-primary hover:underline"
className="mt-2 inline-block text-sm font-medium link"
>
{t('emptyModulesLink')}
</Link>
@@ -134,12 +134,12 @@ export default function AdminModulesPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<Link
href="/admin/modules/grants"
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{t('grantsLink')}
</Link>
@@ -146,11 +146,11 @@ export default function AdminTenantsPage() {
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{t('create')}
</button>
@@ -244,7 +244,7 @@ export default function AdminTenantsPage() {
{/* 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 border border-border bg-card p-6 shadow-lg">
<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">
{editingTenant ? t('edit') : t('create')}
</h2>
@@ -287,13 +287,13 @@ export default function AdminTenantsPage() {
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{tCommon('save')}
</button>
@@ -306,7 +306,7 @@ export default function AdminTenantsPage() {
{/* Delete confirmation modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<p className="text-sm text-foreground mb-4">
{t('deleteConfirm')}
</p>
@@ -314,7 +314,7 @@ export default function AdminTenantsPage() {
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
@@ -128,7 +128,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2>
<button
@@ -227,11 +227,11 @@ export default function AdminUsersPage() {
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{t('create')}
</button>
@@ -349,7 +349,7 @@ export default function AdminUsersPage() {
{/* 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 border border-border bg-card p-6 shadow-lg">
<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">
{editingUser ? t('edit') : t('create')}
</h2>
@@ -455,13 +455,13 @@ export default function AdminUsersPage() {
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{tCommon('save')}
</button>
@@ -474,7 +474,7 @@ export default function AdminUsersPage() {
{/* Delete confirmation modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<p className="text-sm text-foreground mb-4">
{t('deleteConfirm')}
</p>
@@ -490,7 +490,7 @@ export default function AdminUsersPage() {
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
@@ -47,7 +47,7 @@ export default function ChangePasswordPage() {
return (
<div className="mx-auto max-w-md py-8 px-4">
<h1 className="text-2xl font-bold text-foreground mb-6">
<h1 className="text-2xl font-semibold text-foreground mb-6">
{t('changePassword.title')}
</h1>
@@ -129,7 +129,7 @@ export default function ChangePasswordPage() {
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary w-full"
>
{isPending ? (
<svg
+4 -4
View File
@@ -1,4 +1,5 @@
import { getTranslations } from 'next-intl/server';
import { PageHeader } from '@/components/layout/page-header';
import { ChangelogView } from '@/components/changelog/changelog-view';
import { appVersion } from '@/lib/app-version';
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
@@ -22,14 +23,13 @@ export default async function ChangelogPage() {
});
return (
<div className="mx-auto max-w-3xl py-8 px-4">
<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1>
<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>
<div className="mx-auto max-w-3xl p-3 sm:p-6">
<PageHeader title={t('title')} description={t('intro')} className="mb-6" />
{hasUnreleased && (
<div
data-testid="changelog-unreleased-hint"
className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 px-4 py-3 text-sm text-yellow-800 dark:text-yellow-200 mb-6"
className="mb-6 rounded-md border-l-4 border-status-warn bg-status-warn/10 px-4 py-3 text-sm text-foreground"
>
{t('unreleasedHint')}
</div>
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useToastStore } from '../components/Toast';
@@ -32,6 +34,7 @@ interface CatalogModule {
export default function ModuleDetailPage() {
const { slug } = useParams<{ slug: string }>();
const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
const addToast = useToastStore((s) => s.addToast);
@@ -148,7 +151,7 @@ export default function ModuleDetailPage() {
const isActive = module.isActiveForTenant;
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground 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">
<path d="m15 18-6-6 6-6" />
@@ -157,23 +160,11 @@ export default function ModuleDetailPage() {
</Link>
<div className="flex items-start gap-4">
<div className="rounded-lg bg-muted p-4 shrink-0">
{module.icon === 'Globe' ? (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
) : (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
)}
</div>
<ModuleTile slug={module.slug} size={56} />
<div>
<h1 className="text-2xl font-bold text-foreground">{module.name}</h1>
<h1 className="text-2xl font-semibold text-foreground">{module.name}</h1>
<div className="flex items-center gap-2 mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{module.category}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{categoryLabel(module.category)}</span>
<span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span>
</div>
</div>
@@ -190,11 +181,7 @@ export default function ModuleDetailPage() {
type="button"
onClick={handleToggle}
disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
className={`btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
>
{toggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
@@ -45,7 +45,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true">
<div className="max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="max-w-sm rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-2">{t('deactivateDialogTitle')}</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('deactivateDialogBody', { moduleName })}
@@ -55,7 +55,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
type="button"
ref={cancelRef}
onClick={onCancel}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted"
className="btn btn-secondary"
>
{t('deactivateDialogCancel')}
</button>
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CategoryFilterProps {
categories: string[];
@@ -10,17 +11,23 @@ interface CategoryFilterProps {
export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) {
const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
// Dezente Pillen (Fluent „subtle“): gewaehlt = dunkle Fuellung wie bei
// GitHub-Filtern, nicht die gelbe Akzentflaeche.
const chip = (selected: boolean) =>
`inline-flex h-7 items-center rounded-full border px-3 text-xs font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
selected
? 'border-foreground bg-foreground text-background'
: 'border-border bg-card text-foreground hover:bg-muted'
}`;
return (
<div className="flex gap-2 overflow-x-auto pb-1">
<div className="flex gap-2 overflow-x-auto p-0.5 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<button
type="button"
onClick={() => onChange('all')}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
aria-pressed={value === 'all'}
className={chip(value === 'all')}
>
{t('categoryAll')}
</button>
@@ -29,13 +36,10 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
key={cat}
type="button"
onClick={() => onChange(cat)}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === cat
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
aria-pressed={value === cat}
className={chip(value === cat)}
>
{cat}
{categoryLabel(cat)}
</button>
))}
</div>
@@ -1,6 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
/**
* Props for the MarketplaceCard component.
@@ -25,53 +27,6 @@ export interface MarketplaceCardProps {
onLockedClick: () => void;
}
/**
* Default icon mapping for known module icons.
* Renders inline SVG to avoid external icon library dependency.
*/
function ModuleIcon({ icon }: { icon?: string }) {
if (icon === 'Globe') {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
}
// Default puzzle-piece icon for generic modules
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
);
}
/**
* Marketplace card showing a module with activation controls.
*
@@ -84,7 +39,6 @@ export function MarketplaceCard({
name,
slug,
description,
icon,
category,
isActive,
hasAccess,
@@ -95,6 +49,7 @@ export function MarketplaceCard({
onLockedClick,
}: MarketplaceCardProps) {
const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || '';
@@ -114,10 +69,9 @@ export function MarketplaceCard({
return (
<div
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
locked ? 'opacity-60' : 'hover:shadow-md hover:border-primary/30'
className={`group relative flex min-h-[176px] flex-col rounded-lg bg-card p-4 shadow-sm transition-shadow dark:border dark:border-border ${
locked ? 'opacity-60' : isActive ? 'hover:shadow-md' : ''
}`}
style={{ minHeight: '180px' }}
>
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
@@ -126,54 +80,47 @@ export function MarketplaceCard({
type="button"
onClick={handleCardClick}
aria-label={t('openDetail', { name })}
className={`absolute inset-0 rounded-lg ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
className={`absolute inset-0 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
/>
)}
{/* Header: icon + name + badges */}
<div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0">
<ModuleIcon icon={icon} />
</div>
{/* Kopf: App-Kachel + Name + Kategorie */}
<div className="flex items-start gap-3">
<ModuleTile slug={slug} size={40} />
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{category}
</span>
<span
role="status"
className={`rounded-full px-2 py-0.5 text-xs ${
isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-muted text-muted-foreground'
}`}
>
{isActive ? t('statusActive') : t('statusAvailable')}
</span>
{locked && (
<span
className={[
'rounded-full px-2 py-0.5 text-xs',
'bg-amber-100 text-amber-700',
'dark:bg-amber-900/30 dark:text-amber-400',
].join(' ')}
>
{t('statusNoAccess')}
</span>
)}
</div>
<h3 className="truncate text-base font-semibold leading-snug text-foreground">{name}</h3>
<p className="truncate text-xs text-muted-foreground">{categoryLabel(category)}</p>
</div>
</div>
{/* Description */}
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
{/* Beschreibung */}
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{localizedDescription}
</p>
{/* Footer: action button — only rendered for users who may activate/deactivate */}
{canManage && (
<div className="relative flex items-center justify-end">
{/* Fuss: Status links, Aktion rechts */}
<div className="mt-auto flex items-center justify-between gap-2 pt-4">
<div className="flex flex-wrap items-center gap-1.5">
<span
role="status"
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
isActive
? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-muted text-muted-foreground'
}`}
>
{isActive && <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-status-ok" />}
{isActive ? t('statusActive') : t('statusAvailable')}
</span>
{locked && (
<span className="rounded-full bg-status-warn/15 px-2 py-0.5 text-xs font-medium text-status-warn-fg">
{t('statusNoAccess')}
</span>
)}
</div>
{/* Aktion — nur fuer Benutzer, die aktivieren/deaktivieren duerfen */}
{canManage && (
<button
type="button"
onClick={(e) => {
@@ -181,11 +128,7 @@ export function MarketplaceCard({
onToggle(id, isActive);
}}
disabled={isToggling}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
className={`relative btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
>
{isToggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
@@ -195,8 +138,8 @@ export function MarketplaceCard({
t('activate')
)}
</button>
</div>
)}
)}
</div>
</div>
);
}
@@ -23,13 +23,30 @@ export function MarketplaceSearch({ onChange }: MarketplaceSearchProps) {
}, [value, onChange]);
return (
<div className="relative">
<div className="relative max-w-md">
<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"
className="pointer-events-none absolute left-2.5 top-2 text-muted-foreground"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('searchPlaceholder')}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t('searchPlaceholder')}
className="flex h-8 w-full rounded-md border border-input bg-card pl-8 pr-8 text-sm placeholder:text-muted-foreground"
/>
{value && (
<button
@@ -21,25 +21,37 @@ export function StatusFilter({ value, counts, onChange }: StatusFilterProps) {
return (
<div className="flex gap-1 border-b border-border" role="tablist">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={value === tab.key}
onClick={() => onChange(tab.key)}
className={`px-3 py-2 text-sm font-medium transition-colors ${
value === tab.key
? 'text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.label)}
<span className="ml-1.5 text-xs text-muted-foreground">
{counts[tab.key]}
</span>
</button>
))}
{tabs.map((tab) => {
const selected = value === tab.key;
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onChange(tab.key)}
className={`group relative -mb-px inline-flex h-10 items-center px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
selected ? 'font-semibold text-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.label)}
<span
className={`ml-2 rounded-full px-1.5 text-xs font-medium ${
selected ? 'bg-foreground/10 text-foreground' : 'bg-muted text-muted-foreground'
}`}
>
{counts[tab.key]}
</span>
{/* Fluent-Reiter: 2 px Akzentlinie unten, beim Zeigen eine graue */}
<span
aria-hidden="true"
className={`absolute inset-x-3 bottom-0 h-[2px] rounded-full ${
selected ? 'bg-primary-strong' : 'bg-transparent group-hover:bg-border'
}`}
/>
</button>
);
})}
</div>
);
}
@@ -37,8 +37,8 @@ export function TenantContextSelector() {
if (!isSuperAdmin) return null;
return (
<div className="bg-muted/50 rounded-lg p-3 mb-4 border border-border">
<label htmlFor="tenant-context-select" className="block text-xs font-semibold text-muted-foreground mb-1.5">
<div className="flex flex-wrap items-center gap-3 rounded-lg bg-card px-4 py-3 shadow-sm dark:border dark:border-border">
<label htmlFor="tenant-context-select" className="text-sm font-semibold text-foreground">
{t('tenantContextLabel')}
</label>
{loading ? (
@@ -48,7 +48,7 @@ export function TenantContextSelector() {
id="tenant-context-select"
onChange={(e) => setSelectedTenantId(e.target.value || null)}
defaultValue=""
className="bg-card border border-border rounded-md px-3 py-1.5 text-sm min-w-[200px]"
className="h-8 min-w-[200px] rounded-md border border-input bg-card px-2.5 text-sm"
>
<option value="">—</option>
{tenants.map((tenant) => (
@@ -39,7 +39,7 @@ export function ToastContainer() {
{toasts.map((toast) => (
<div
key={toast.id}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
className="flex items-center gap-3 rounded-lg bg-card dark:border dark:border-border p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
>
{toast.type === 'success' ? (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400 shrink-0">
+18 -13
View File
@@ -10,6 +10,7 @@ import { MarketplaceSearch } from './components/MarketplaceSearch';
import { StatusFilter, type StatusValue } from './components/StatusFilter';
import { CategoryFilter } from './components/CategoryFilter';
import { ToastContainer, useToastStore } from './components/Toast';
import { PageHeader } from '@/components/layout/page-header';
import { TenantContextSelector } from './components/TenantContextSelector';
import { ActivationDialog } from './components/ActivationDialog';
@@ -179,13 +180,10 @@ export default function MarketplacePage() {
};
return (
<div className="space-y-6">
<TenantContextSelector />
<div className="mx-auto max-w-7xl space-y-6 p-3 sm:p-6">
<PageHeader title={t('title')} description={t('subtitle')} />
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
</div>
<TenantContextSelector />
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
@@ -199,29 +197,30 @@ export default function MarketplacePage() {
</div>
) : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<span className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground/40 mb-4"
>
<path d="M21 8a2 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.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<h2 className="text-lg font-semibold text-foreground">{t('emptyTitle')}</h2>
</span>
<h2 className="text-base font-semibold text-foreground">{t('emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
</div>
) : (
<>
<div className="space-y-4">
<div className="space-y-3">
<MarketplaceSearch onChange={setSearchQuery} />
<StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} />
<CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} />
@@ -229,7 +228,13 @@ export default function MarketplacePage() {
{filteredModules.length === 0 && hasFiltersApplied ? (
<div className="flex flex-col items-center justify-center min-h-[30vh] text-center">
<h2 className="text-lg font-semibold text-foreground">{t('filteredEmptyTitle')}</h2>
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</span>
<h2 className="text-base font-semibold text-foreground">{t('filteredEmptyTitle')}</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('filteredEmptyBody')}</p>
</div>
) : (
@@ -38,16 +38,16 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
@@ -57,13 +57,13 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
<path d="m9 9 6 6" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">{t('notFound')}</h2>
<h2 className="mb-1 text-base font-semibold">{t('notFound')}</h2>
<p className="text-sm text-muted-foreground mb-6">
{t('notFoundDescription')}
</p>
<Link
href={`/modules/${category}`}
className="text-sm font-medium text-primary hover:underline"
className="btn btn-secondary"
>
{t('backToCategory')}
</Link>
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import Link from 'next/link';
/**
@@ -14,54 +15,6 @@ export interface ModuleCardProps {
category: string;
}
/**
* Default icon mapping for known module icons.
* Renders inline SVG to avoid external icon library dependency.
*/
function ModuleIcon({ icon }: { icon?: string }) {
// Globe icon for domain-related modules
if (icon === 'Globe') {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
}
// Default puzzle-piece icon for generic modules
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
);
}
/**
* Module card for the category grid view.
*
@@ -69,7 +22,7 @@ function ModuleIcon({ icon }: { icon?: string }) {
* to the expanded module view. Per D-05a: uniform card appearance,
* compact, looks good even with a single module.
*/
export function ModuleCard({ name, slug, description, icon, category }: ModuleCardProps) {
export function ModuleCard({ name, slug, description, category }: ModuleCardProps) {
const t = useTranslations('modules');
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || '';
@@ -79,14 +32,12 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
href={`/modules/${category}/${slug}`}
className="group block"
>
<div className="rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:shadow-md hover:border-primary/30 group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2">
<div className="rounded-lg bg-card p-4 shadow-sm transition-shadow hover:shadow-md dark:border dark:border-border group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2">
{/* Header: icon + name */}
<div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0">
<ModuleIcon icon={icon} />
</div>
<ModuleTile slug={slug} size={40} />
<div className="min-w-0">
<h3 className="font-semibold text-base leading-tight truncate">
<h3 className="truncate pt-2 text-base font-semibold leading-snug">
{name}
</h3>
</div>
@@ -99,7 +50,7 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
{/* Footer: open link */}
<div className="flex justify-end">
<span className="text-sm font-medium text-primary group-hover:underline">
<span className="text-sm font-medium text-link group-hover:underline">
{t('openModule')}
</span>
</div>
@@ -95,7 +95,7 @@ export default function CategoryPage() {
<div className="space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">
<h1 className="text-2xl font-semibold tracking-tight">
{t('categoryTitle', { category: categoryTitle })}
</h1>
</div>
@@ -76,7 +76,7 @@ export function ConvertTab({ file, pemText, password, onTargetFormatChange }: Co
type="button"
onClick={handleConvert}
disabled={loading || !canConvert}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.convert')}
</button>
@@ -46,7 +46,7 @@ export function InspectTab({ file, pemText, password }: InspectTabProps) {
type="button"
onClick={handleInspect}
disabled={loading || !canInspect}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.inspect')}
</button>
@@ -139,7 +139,7 @@ export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
data-testid="merge-action-btn"
onClick={handleMerge}
disabled={loading || !canMerge}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.merge')}
</button>
@@ -82,7 +82,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
type="button"
onClick={handleSplit}
disabled={loading || !file}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.split')}
</button>
@@ -1,5 +1,6 @@
'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { DropZone } from './components/DropZone';
@@ -98,15 +99,11 @@ export default function CertManagerPage() {
};
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
{/* Shared input card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
{/* DropZone */}
<DropZone
onFile={handleFile}
@@ -148,7 +145,7 @@ export default function CertManagerPage() {
onClick={() => handleTabChange(tab)}
className={`pb-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'border-b-2 border-primary text-foreground'
? 'border-b-2 border-primary-strong font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
@@ -158,7 +155,7 @@ export default function CertManagerPage() {
</nav>
{/* Tab content card */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
{renderActiveTab()}
</div>
</div>
@@ -61,7 +61,7 @@ export function ExportFileList({ historyItems }: ExportFileListProps) {
<a
href={exportFileUrl(filename)}
download
className="text-sm text-primary hover:underline"
className="text-sm link"
>
{filename}
</a>
@@ -89,7 +89,7 @@ export function InvoiceHistoryTable({
const totalPages = Math.ceil(total / 25);
const theadCls =
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
'px-4 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
return (
<div className="rounded border border-border overflow-hidden">
@@ -170,7 +170,7 @@ export function InvoiceHistoryTable({
<a
href={exportFileUrl(row.exportFilename)}
download
className="inline-flex items-center gap-1 text-primary text-sm hover:underline"
className="inline-flex items-center gap-1 link text-sm"
>
<DownloadIcon />
{row.exportFilename}
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api';
import { InvoiceHistoryTable } from './components/InvoiceHistoryTable';
import { ExportFileList } from './components/ExportFileList';
@@ -75,51 +76,47 @@ export default function DkvFleetPage() {
};
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
{/* Page Header Row */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t('pageTitle')}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{lastChecked
? t('lastChecked', { time: formatLastChecked(lastChecked) })
: t('neverChecked')}
</p>
</div>
{/* Einstellungen-Link */}
<Link
href={`${basePath}/settings`}
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t('settingsTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
{/* Seitenkopf (Design „Mosaik“): App-Kachel, Titel, Stand; Aktionen rechts */}
<PageHeader
moduleSlug="dkv-fleet"
title={t('pageTitle')}
description={
lastChecked
? t('lastChecked', { time: formatLastChecked(lastChecked) })
: t('neverChecked')
}
actions={
<>
<Link
href={`${basePath}/settings`}
className="btn btn-secondary btn-icon"
title={t('settingsTitle')}
aria-label={t('settingsTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</Link>
{/* "Jetzt prüfen" button per 07-UI-SPEC button states */}
<button
type="button"
onClick={handleCheckNow}
disabled={isChecking}
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
isChecking ? ' opacity-80 cursor-not-allowed' : ''
}`}
>
{isChecking ? (
<>
<SpinnerIcon />
{t('checking')}
</>
) : (
t('checkNow')
)}
</button>
</div>
</Link>
<button
type="button"
onClick={handleCheckNow}
disabled={isChecking}
className="btn btn-primary"
>
{isChecking ? (
<>
<SpinnerIcon />
{t('checking')}
</>
) : (
t('checkNow')
)}
</button>
</>
}
/>
{/* Error banner — conditional per UI-SPEC */}
{pollError && (
@@ -103,14 +103,14 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button
type="button"
onClick={handleButtonClick}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
className="btn btn-secondary"
>
{t('importCsv')}
</button>
{/* Import mode inline dialog (below button, per UI-SPEC) */}
{showDialog && selectedFile && (
<div className="absolute right-0 top-10 z-30 w-80 rounded border border-border bg-card p-4 shadow-md">
<div className="absolute right-0 top-10 z-30 w-80 rounded bg-card dark:border dark:border-border p-4 shadow-md">
<div className="mb-3 rounded bg-muted/50 px-3 py-2 text-xs text-muted-foreground space-y-0.5">
<p>{t('csvFormatHelp')}</p>
<p className="font-mono">{t('csvFormatExample')}</p>
@@ -185,7 +185,7 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button
type="button"
onClick={handleCancel}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
className="btn btn-secondary"
>
{t('form.cancelDialog')}
</button>
@@ -525,7 +525,7 @@ export function InboxConfigForm() {
type="button"
onClick={handleTestConnection}
disabled={isTesting || isSaving}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{isTesting ? t('form.testTesting') : t('form.testConnection')}
</button>
@@ -533,7 +533,7 @@ export function InboxConfigForm() {
type="button"
onClick={handleSave}
disabled={isSaving || isTesting}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{t('form.save')}
</button>
@@ -542,7 +542,7 @@ export function InboxConfigForm() {
type="button"
onClick={handleCheckNow}
disabled={isChecking || isSaving || isTesting}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{isChecking ? 'Prüfe...' : 'Jetzt prüfen'}
</button>
@@ -77,7 +77,7 @@ const vehicleToEdit = (v: DkvVehicle): EditState => ({
// --- Shared classes ---
const theadCls =
'px-3 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
'px-3 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
const iconBtnCls =
'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors';
const editInputCls =
@@ -95,7 +95,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
const t = useTranslations('dkvFleet');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="rounded border border-border bg-card px-6 py-5 shadow-lg max-w-sm w-full">
<div className="rounded bg-card dark:border dark:border-border px-6 py-5 shadow-lg max-w-sm w-full">
<h3 className="text-base font-semibold text-foreground mb-2">
{t('deleteVehicle')}
</h3>
@@ -107,7 +107,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{t('form.cancelDialog')}
</button>
@@ -132,7 +132,7 @@ interface ToastProps {
function Toast({ message }: ToastProps) {
return (
<div className="fixed bottom-4 right-4 z-50 rounded border border-border bg-card shadow-md px-4 py-3 text-sm">
<div className="fixed bottom-4 right-4 z-50 rounded bg-card dark:border dark:border-border shadow-md px-4 py-3 text-sm">
{message}
</div>
);
@@ -317,7 +317,7 @@ export function VehicleTable() {
type="button"
onClick={startAdd}
disabled={isAddingNew}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{t('addVehicle')}
</button>
@@ -408,7 +408,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')}
onClick={() => saveEdit(v.id)}
disabled={isSavingRow}
className={`${iconBtnCls} hover:text-primary`}
className={`${iconBtnCls} hover:text-foreground`}
>
<CheckIcon />
</button>
@@ -515,7 +515,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')}
onClick={saveNew}
disabled={isSavingNew}
className={`${iconBtnCls} hover:text-primary`}
className={`${iconBtnCls} hover:text-foreground`}
>
<CheckIcon />
</button>
@@ -24,7 +24,7 @@ export default function DkvFleetSettingsPage() {
const tabCls = (tab: ActiveTab) =>
`px-4 py-2 text-sm transition-colors ${
activeTab === tab
? 'border-b-2 border-primary text-foreground font-medium'
? 'border-b-2 border-primary-strong text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground'
}`;
@@ -33,7 +33,7 @@ export function DomainInput({ onSubmit, isLoading }: DomainInputProps) {
<button
type="submit"
disabled={isLoading || !domain.trim()}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:pointer-events-none disabled:opacity-50"
className="btn btn-primary disabled:pointer-events-none"
>
{isLoading ? t('checking') : t('checkButton')}
</button>
@@ -33,7 +33,7 @@ export function ResultList({ results }: ResultListProps) {
{suggestions.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<p className="text-xs font-medium text-muted-foreground">
{t('suggestions')}
</p>
{suggestions.map((result) => (
@@ -1,5 +1,6 @@
'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { type DomainCheckResult, checkDomainAction } from './actions';
@@ -37,17 +38,11 @@ export default function DomaincheckPage() {
};
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('description')}
</p>
</div>
<div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="domaincheck" title={t('title')} description={t('description')} />
{/* Card panel (per D-05a) */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm space-y-4">
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
{/* Domain input */}
<DomainInput onSubmit={handleSubmit} isLoading={isLoading} />
@@ -443,8 +443,8 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
data-testid="server-card"
data-health={health}
aria-label={`${server.name}: ${t(`health.${health}`)}`}
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${
isOrphan ? 'border-dashed border-border' : 'border-border'
className={`relative flex h-full flex-col overflow-hidden rounded-lg bg-card ${
isOrphan ? 'border border-dashed border-border' : 'dark:border dark:border-border'
} ${style.shadow}`}
>
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { useAuthStore } from '@/lib/stores/auth-store';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from '@/components/proxmox/HealthBar';
@@ -36,7 +37,7 @@ function RefreshIcon({ spinning }: { spinning: boolean }) {
function SkeletonCard() {
return (
<div className="rounded-xl border border-border bg-card p-4 pl-5 shadow-[0_1px_2px_oklch(0_0_0/0.06)]">
<div className="rounded-xl bg-card p-4 pl-5 shadow-sm dark:border dark:border-border">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-muted" />
<div className="flex-1 space-y-2">
@@ -106,33 +107,31 @@ export default function ProxmoxPage() {
return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
{isAdmin && (
<div className="flex items-center gap-4">
<Link
href="/modules/proxmox/settings"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{t('card.settingsNav')}
</Link>
{servers !== null && servers.length > 0 && (
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-70"
>
<RefreshIcon spinning={isRefreshing} />
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button>
)}
</div>
)}
</div>
<PageHeader
moduleSlug="proxmox"
title={t('title')}
description={t('description')}
actions={
isAdmin ? (
<>
<Link href="/modules/proxmox/settings" className="btn btn-subtle">
{t('card.settingsNav')}
</Link>
{servers !== null && servers.length > 0 && (
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="btn btn-primary"
>
<RefreshIcon spinning={isRefreshing} />
{isRefreshing ? t('card.refreshing') : t('card.refresh')}
</button>
)}
</>
) : undefined
}
/>
{error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
@@ -179,7 +179,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
};
return (
<div className="space-y-4 rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="space-y-4 rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm">
<div>
<label htmlFor="proxmox-name" className={labelCls}>
{t('settings.nameLabel')}
@@ -368,7 +368,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button"
onClick={handleSave}
disabled={isSaving || !form.name || !form.baseUrl}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isSaving ? t('settings.saving') : t('settings.save')}
</button>
@@ -383,7 +383,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button"
onClick={handleTest}
disabled={isTesting || !form.baseUrl}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button>
@@ -17,7 +17,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
const t = useTranslations('proxmox');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded border border-border bg-card px-6 py-5 shadow-lg">
<div className="w-full max-w-sm rounded bg-card dark:border dark:border-border px-6 py-5 shadow-lg">
<h3 className="mb-2 text-base font-semibold text-foreground">
{t('settings.deleteConfirmTitle')}
</h3>
@@ -29,7 +29,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
className="btn btn-secondary"
>
{t('settings.deleteCancelButton')}
</button>
@@ -119,7 +119,7 @@ export default function ProxmoxSettingsPage() {
<button
type="button"
onClick={() => setEditingId('new')}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="btn btn-primary"
>
{t('settings.addServer')}
</button>
@@ -155,7 +155,7 @@ export default function ProxmoxSettingsPage() {
) : (
<li
key={server.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 shadow-sm"
className="flex items-center justify-between rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm"
>
<div>
<div className="font-medium">{server.name}</div>
@@ -167,7 +167,7 @@ export default function ProxmoxSettingsPage() {
<button
type="button"
onClick={() => setEditingId(server.id)}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
className="btn btn-secondary"
>
{t('settings.edit')}
</button>
@@ -157,7 +157,7 @@ export function FilterPanel() {
const labelCls = 'mb-1 block text-sm text-foreground';
return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-4">
<div className="rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1">
<label htmlFor="tr-filter-q" className={labelCls}>
@@ -297,7 +297,7 @@ export function FilterPanel() {
autoComplete="off"
/>
{cpvMatches.length > 0 && (
<ul className="absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded border border-border bg-card shadow-md">
<ul className="absolute z-10 mt-1 w-full max-h-56 overflow-auto rounded bg-card dark:border dark:border-border shadow-md">
{cpvMatches.map((d) => (
<li key={d.code}>
<button
@@ -65,7 +65,7 @@ export function PollNowButton({ onPolled }: PollNowButtonProps) {
onClick={handlePoll}
disabled={isPolling}
title={t('page.pollNowTitle')}
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
className={`btn btn-primary flex items-center${
isPolling ? ' opacity-80 cursor-not-allowed' : ''
}`}
>
@@ -210,7 +210,7 @@ export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
const totalPages = Math.max(1, Math.ceil(total / limit));
const thStaticCls =
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
'px-4 py-3 text-xs font-semibold text-muted-foreground border-b border-border text-left';
const thSortCls = `${thStaticCls} cursor-pointer select-none hover:text-foreground transition-colors`;
return (
@@ -219,7 +219,7 @@ export function SavedSearchBar() {
};
return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3">
<div className="rounded-lg bg-card dark:border dark:border-border p-4 shadow-sm space-y-3">
<h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2>
<div className="flex flex-wrap items-end gap-2">
@@ -276,7 +276,7 @@ export function SavedSearchBar() {
type="button"
aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
onClick={() => confirmRename(profile.id)}
className="text-xs font-medium text-primary hover:underline"
className="text-xs font-medium link"
>
{t('savedSearch.save')}
</button>
@@ -67,7 +67,7 @@ function formatPublished(value: string): string {
}
function fieldLabelCls() {
return 'text-xs font-semibold uppercase tracking-wider text-muted-foreground';
return 'text-xs font-semibold text-muted-foreground';
}
/**
@@ -234,7 +234,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
className="inline-block text-sm font-medium link"
>
{t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })}
</a>
@@ -251,7 +251,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
href={tender.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80"
className="mt-2 inline-block text-sm font-medium link"
>
{t('detail.sourceLinkFallback')}
</a>
@@ -76,7 +76,7 @@ export default function TenderRadarMySourcesPage() {
<div className="mt-8 border-t border-border pt-6">
<Link
href="/modules/tender-radar/settings"
className="text-sm text-primary hover:underline"
className="text-sm link"
>
{t('page.settingsTitle')} &rarr;
</Link>
@@ -4,6 +4,7 @@ import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { CoverageBanner } from './components/CoverageBanner';
import { FilterPanel } from './components/FilterPanel';
import { PollNowButton } from './components/PollNowButton';
@@ -59,42 +60,41 @@ function TenderRadarContent() {
};
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight">
{t('page.title')}
</h1>
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
<div className="flex items-center gap-2">
{/*
* Zahnrad-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): tender-radar routes only
* exist as literal paths — the dynamic settings route returns
* "Modul nicht gefunden" for this module, so the gear must point
* at the literal path directly.
*
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the
* admin-only settings page — that's the page every module user
* (not just administrators) is allowed to configure something
* on; administrators reach the platform-wide settings from there
* with one more click.
*/}
<Link
href="/modules/tender-radar/my-sources"
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t('page.mySourcesTitle')}
aria-label={t('page.mySourcesTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<PageHeader
moduleSlug="tender-radar"
title={t('page.title')}
actions={
<>
{/*
* Zahnrad-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): tender-radar routes only
* exist as literal paths — the dynamic settings route returns
* "Modul nicht gefunden" for this module, so the gear must point
* at the literal path directly.
*
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the
* admin-only settings page — that's the page every module user
* (not just administrators) is allowed to configure something
* on; administrators reach the platform-wide settings from there
* with one more click.
*/}
<Link
href="/modules/tender-radar/my-sources"
className="btn btn-secondary btn-icon"
title={t('page.mySourcesTitle')}
aria-label={t('page.mySourcesTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</Link>
</Link>
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
</div>
</div>
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
</>
}
/>
<CoverageBanner />
<SavedSearchBar />
@@ -449,7 +449,7 @@ export function EmailAlertConfigForm() {
type="button"
onClick={handleTestConnection}
disabled={isTesting || isSaving}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{isTesting ? t('emailAlerts.testTesting') : t('emailAlerts.testConnection')}
</button>
@@ -457,7 +457,7 @@ export function EmailAlertConfigForm() {
type="button"
onClick={handleSave}
disabled={isSaving || isTesting}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button>
@@ -230,7 +230,7 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
type="button"
onClick={handleAdd}
disabled={isAdding}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')}
</button>
@@ -216,7 +216,7 @@ export function SourceConfigForm() {
type="button"
onClick={handleSave}
disabled={isSaving}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')}
</button>
@@ -71,7 +71,7 @@ export default function TenderRadarSettingsPage() {
</p>
<Link
href="/modules/tender-radar/my-sources"
className="text-sm text-primary hover:underline"
className="text-sm link"
>
{t('mySources.title')} &rarr;
</Link>
+18 -12
View File
@@ -11,12 +11,16 @@ const messages: Record<string, Record<string, string>> = {
editMode: 'Dashboard bearbeiten',
saveChanges: 'Änderungen speichern',
addWidget: 'Widget hinzufügen',
editShort: 'Bearbeiten',
done: 'Fertig',
toolbarLabel: 'Dashboard-Aktionen',
layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
},
};
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key,
useLocale: () => 'de',
}));
const mockStore = {
@@ -106,45 +110,47 @@ afterEach(() => {
});
describe('DashboardPage (quick-260916-dyv)', () => {
it('Test 1: Ansichtsmodus — Stift in fester Leiste unten rechts, kein "Widget hinzufuegen", kein mt-8/top-2, Grid direkt im Container p-2', async () => {
it('Test 1: Ansichtsmodus — Befehlsleiste rechts oben mit „Bearbeiten“, kein "Widget hinzufuegen", Begruessung, Grid direkt im Container p-2 (Design „Mosaik“)', async () => {
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' });
const bar = toggle.parentElement as HTMLElement;
expect(bar.classList.contains('fixed')).toBe(true);
expect(bar.classList.contains('bottom-6')).toBe(true);
expect(bar.classList.contains('right-6')).toBe(true);
expect(bar.classList.contains('z-20')).toBe(true);
const toolbar = screen.getByRole('toolbar', { name: 'Dashboard-Aktionen' });
const toggle = screen.getByRole('button', { name: 'Bearbeiten' });
expect(toggle.parentElement).toBe(toolbar);
expect(toggle).toHaveAttribute('aria-pressed', 'false');
// kein schwebender Knopf mehr unten rechts
expect(toolbar.classList.contains('fixed')).toBe(false);
expect(document.querySelector('.bottom-6')).toBeNull();
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
expect(document.querySelector('.mt-8')).toBeNull();
expect(document.querySelector('.top-2')).toBeNull();
// Runde 3: die Begruessung steht in der Seitenleiste, nicht mehr hier.
expect(screen.queryByTestId('dashboard-greeting')).toBeNull();
const grid = screen.getByTestId('dashboard-grid');
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
expect(grid.getAttribute('data-edit')).toBe('false');
});
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben dem Haekchen in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben „Fertig“ in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
mockStore.isEditMode = true;
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Änderungen speichern' });
const toggle = screen.getByRole('button', { name: 'Fertig' });
const add = screen.getByRole('button', { name: 'Widget hinzufügen' });
expect(add.parentElement).toBe(toggle.parentElement);
// "Widget hinzufuegen" steht im DOM VOR dem Umschalter (links daneben).
expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true');
});
it('Test 3: Umschalten — Klick auf "Dashboard bearbeiten" ruft setEditMode(true) genau einmal', async () => {
it('Test 3: Umschalten — Klick auf "Bearbeiten" ruft setEditMode(true) genau einmal', async () => {
const { default: DashboardPage } = await import('./page');
render(<DashboardPage />);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard bearbeiten' }));
fireEvent.click(screen.getByRole('button', { name: 'Bearbeiten' }));
expect(mockStore.setEditMode).toHaveBeenCalledTimes(1);
expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
});
+58 -35
View File
@@ -5,6 +5,8 @@ import { useTranslations } from 'next-intl';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
import { useAuthStore } from '@/lib/stores/auth-store';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { registerWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
@@ -45,6 +47,13 @@ interface ActiveModule {
export default function DashboardPage() {
const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false);
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
const [gridInset, setGridInset] = useState(0);
// Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage).
const userId = useAuthStore((s) => s.user?.id);
const { background, choose: chooseBackground } = useDashboardBackground(userId);
const hasBackground = background.kind !== 'none';
// quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
@@ -115,7 +124,8 @@ export default function DashboardPage() {
}
return (
<div className="relative p-2">
<div className={`relative p-2 ${hasBackground ? 'dashboard-has-bg' : ''}`}>
<DashboardBackdrop background={background} />
{/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
@@ -131,33 +141,27 @@ export default function DashboardPage() {
onReorder={reorderDashboards}
/>
{isSwitchingDashboard ? (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
</div>
) : (
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
)}
{/* Feste Aktionsleiste unten rechts.
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
{isEditMode && (
<button
type="button"
onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<svg
{/* Befehlsleiste rechts ueber dem Raster (Design „Mosaik“, Fluent
„command bar“). Die Begruessung sitzt seit Runde 3 unten in der
Seitenleiste; hier bleibt nur die Leiste. Mit Hintergrund liegt sie
auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
<div
className="flex justify-end px-3 pt-1 transition-[padding] duration-200 ease-out motion-reduce:transition-none"
style={gridInset > 0 ? { paddingInline: 12 + gridInset } : undefined}
>
<div
role="toolbar"
aria-label={t('toolbarLabel')}
className="dashboard-chrome flex items-center gap-1 rounded-lg"
>
{isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
{isEditMode && (
<button
type="button"
onClick={() => setCatalogOpen(true)}
className="btn btn-subtle"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -172,15 +176,34 @@ export default function DashboardPage() {
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t('addWidget')}
</button>
)}
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
{t('addWidget')}
</button>
)}
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
</div>
{isSwitchingDashboard ? (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
</div>
) : (
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
onOpenCatalog={() => setCatalogOpen(true)}
onQuickAdd={(type) => addWidget(type)}
onInsetChange={setGridInset}
/>
)}
{/* Widget catalog modal */}
<WidgetCatalogModal
isOpen={catalogOpen}
@@ -17,9 +17,9 @@ export default function SettingsLayout({
const t = useTranslations('settings');
return (
<div className="flex h-full">
<div className="flex h-full flex-col md:flex-row">
<SettingsSidebar />
<div className="flex-1 overflow-y-auto p-6">
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
{/* Back to Dashboard link */}
<Link
href="/"
+454 -42
View File
@@ -57,45 +57,90 @@
--color-status-idle-fg: var(--status-idle-fg);
--color-status-orphan-fg: var(--status-orphan-fg);
--color-well: var(--well);
--color-link: var(--link);
--color-tile: var(--tile);
--color-tile-foreground: var(--tile-foreground);
--color-primary-strong: var(--primary-strong);
--color-input-strong: var(--input-strong);
--color-appbar: var(--appbar);
--color-appbar-foreground: var(--appbar-foreground);
--color-appbar-muted: var(--appbar-muted);
--color-appbar-hover: var(--appbar-hover);
--color-sidebar-hover: var(--sidebar-hover);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
}
/* Fluent-2-Hoehenstufen: shadow-sm = shadow2 (ruhende Karte), shadow-md =
shadow4 (angehobene/Hover-Karte), shadow-lg = shadow16 (Menues, Dialoge).
Im dunklen Erscheinungsbild tragen Haarlinien statt Schatten (--border). */
@theme {
--shadow-sm: 0 0 2px oklch(0 0 0 / 0.12), 0 1px 2px oklch(0 0 0 / 0.14);
--shadow-md: 0 0 2px oklch(0 0 0 / 0.12), 0 4px 8px oklch(0 0 0 / 0.14);
--shadow-lg: 0 0 2px oklch(0 0 0 / 0.12), 0 8px 16px oklch(0 0 0 / 0.14);
--shadow-xl: 0 0 8px oklch(0 0 0 / 0.12), 0 14px 28px oklch(0 0 0 / 0.14);
}
:root {
--radius: 0.5rem;
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
/* Design „Mosaik“ (Fluent 2 / GitHub als Vorbild): 6 px fuer Bedien-
elemente (rounded-md), 8 px fuer Karten (rounded-lg), 12 px fuer grosse
Flaechen (rounded-xl). */
--radius: 0.375rem;
/* Segoe UI Variable zuerst: unter Windows (Desktop-Client) wirkt die
Oberflaeche damit wie eine native Anwendung; sonst Inter bzw. Systemschrift. */
--font-sans: "Segoe UI Variable Text", "Segoe UI", Inter, system-ui, -apple-system, sans-serif;
/* Layout dimensions */
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
--header-height: 60px;
--header-height: 48px;
/* Light mode colors (OKLCH) */
--background: oklch(0.99 0 0);
--foreground: oklch(0.15 0 0);
/* Light mode colors (OKLCH) — Arbeitsflaeche leicht kuehlgrau, Karten weiss */
--background: oklch(0.972 0.003 260);
--foreground: oklch(0.2 0.006 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.15 0 0);
--card-foreground: oklch(0.2 0.006 260);
--primary: oklch(0.91 0.19 102);
--primary-foreground: oklch(0.20 0.02 90);
--secondary: oklch(0.96 0 0);
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0.96 0 0);
--muted-foreground: oklch(0.55 0 0);
--accent: oklch(0.96 0 0);
--accent-foreground: oklch(0.15 0 0);
--secondary: oklch(0.955 0.003 260);
--secondary-foreground: oklch(0.2 0.006 260);
--muted: oklch(0.955 0.003 260);
--muted-foreground: oklch(0.49 0.01 260);
--accent: oklch(0.955 0.003 260);
--accent-foreground: oklch(0.2 0.006 260);
--destructive: oklch(0.55 0.2 27);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.90 0 0);
--input: oklch(0.90 0 0);
--ring: oklch(0.91 0.19 102);
--border: oklch(0.915 0.004 260);
--input: oklch(0.87 0 0);
/* Untere Kante von Eingabefeldern (Fluent-Eingabefeld). */
--input-strong: oklch(0.58 0.005 260);
/* Tastaturfokus: dunkler Ring statt Gelb — Gelb auf Weiss erreicht nur
1,3:1 und waere als Fokusanzeige kaum sichtbar. */
--ring: oklch(0.3 0.01 260);
/* Verweise: nie gelbe Schrift auf Weiss. 5,9:1 auf --card. */
--link: oklch(0.50 0.15 255);
/* Neutrale Modul-Kachel (Runde 2: einfarbig statt bunt). */
--tile: oklch(0.93 0.004 260);
--tile-foreground: oklch(0.35 0.01 260);
/* Akzent fuer duenne Markierungen (Auswahlpille, Reiter-Unterstrich) auf
hellem Grund: die Akzentfarbe um knapp ein Drittel abgedunkelt, damit
auch Gelb neben Hellgrau noch erkennbar bleibt. */
--primary-strong: color-mix(in oklab, var(--primary) 70%, oklch(0.3 0.03 90));
/* Kopfzeile (dunkle App-Leiste wie GitHub / Microsoft 365) */
--appbar: oklch(0.24 0.012 260);
--appbar-foreground: oklch(0.985 0 0);
--appbar-muted: oklch(0.985 0 0 / 0.72);
--appbar-hover: oklch(1 0 0 / 0.08);
/* Sidebar tokens */
--sidebar: oklch(0.97 0 0);
--sidebar-foreground: oklch(0.15 0 0);
--sidebar-accent: oklch(0.93 0 0);
--sidebar-accent-foreground: oklch(0.25 0 0);
--sidebar-border: oklch(0.90 0 0);
--sidebar: oklch(0.975 0.003 260);
--sidebar-foreground: oklch(0.2 0.006 260);
--sidebar-accent: oklch(0.925 0.005 260);
--sidebar-accent-foreground: oklch(0.16 0.006 260);
--sidebar-hover: oklch(0.945 0.004 260);
--sidebar-border: oklch(0.915 0.004 260);
/* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem
Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis
@@ -113,34 +158,46 @@
--status-idle-fg: oklch(0.50 0.02 260);
--status-orphan-fg: oklch(0.50 0.01 260);
/* Eingelassene Messfelder. */
--well: oklch(0.975 0.003 260);
--well: oklch(0.972 0.003 260);
}
.dark {
--background: oklch(0.23 0.01 260);
--foreground: oklch(0.95 0 0);
--card: oklch(0.27 0.01 260);
--card-foreground: oklch(0.95 0 0);
--background: oklch(0.2 0.01 260);
--foreground: oklch(0.95 0.003 260);
--card: oklch(0.245 0.01 260);
--card-foreground: oklch(0.95 0.003 260);
--primary: oklch(0.91 0.19 102);
--primary-foreground: oklch(0.20 0.02 90);
--secondary: oklch(0.30 0.01 260);
--secondary-foreground: oklch(0.95 0 0);
--muted: oklch(0.30 0.01 260);
--muted-foreground: oklch(0.65 0 0);
--accent: oklch(0.30 0.01 260);
--accent-foreground: oklch(0.95 0 0);
--destructive: oklch(0.55 0.2 27);
--secondary: oklch(0.28 0.01 260);
--secondary-foreground: oklch(0.95 0.003 260);
--muted: oklch(0.28 0.01 260);
--muted-foreground: oklch(0.72 0.01 260);
--accent: oklch(0.28 0.01 260);
--accent-foreground: oklch(0.95 0.003 260);
--destructive: oklch(0.6 0.2 27);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.30 0.01 260);
--input: oklch(0.30 0.01 260);
--ring: oklch(0.91 0.19 102);
/* Dunkel: feine helle Haarlinie statt Schatten. */
--border: oklch(1 0 0 / 0.09);
--input: oklch(1 0 0 / 0.16);
--input-strong: oklch(1 0 0 / 0.42);
--ring: oklch(0.92 0.005 260);
--link: oklch(0.78 0.11 250);
--tile: oklch(0.32 0.01 260);
--tile-foreground: oklch(0.88 0.005 260);
--primary-strong: var(--primary);
--appbar: oklch(0.18 0.01 260);
--appbar-foreground: oklch(0.985 0 0);
--appbar-muted: oklch(0.985 0 0 / 0.7);
--appbar-hover: oklch(1 0 0 / 0.08);
/* Sidebar tokens (dark) */
--sidebar: oklch(0.20 0.01 260);
--sidebar-foreground: oklch(0.95 0 0);
--sidebar-accent: oklch(0.91 0.19 102 / 0.15);
--sidebar-accent-foreground: oklch(0.91 0.19 102);
--sidebar-border: oklch(0.30 0.01 260);
--sidebar: oklch(0.21 0.01 260);
--sidebar-foreground: oklch(0.93 0.003 260);
--sidebar-accent: oklch(1 0 0 / 0.08);
--sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-hover: oklch(1 0 0 / 0.05);
--sidebar-border: oklch(1 0 0 / 0.08);
/* Statusfarben (dark). Textvariante auf --card gemessen: ok 7,4:1,
warn 8,5:1, down 6,1:1, idle 6,1:1, orphan 5,6:1. */
@@ -154,11 +211,13 @@
--status-down-fg: oklch(0.74 0.16 27);
--status-idle-fg: oklch(0.72 0.02 260);
--status-orphan-fg: oklch(0.70 0.01 260);
--well: oklch(0.235 0.01 260);
--well: oklch(0.215 0.01 260);
}
body {
font-family: var(--font-sans);
font-size: 0.875rem;
line-height: 1.25rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -181,3 +240,356 @@ body {
.wmde-markdown ul ul { list-style: circle; }
.wmde-markdown ol { list-style: decimal; }
.wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none; }
/* Tastaturfokus sichtbar ueberall, auch wo ein Bauteil keinen eigenen
Fokusring setzt (im Basis-Layer, damit `outline-none` + eigener Ring
eines Bauteils Vorrang behalten). Mausklicks loesen :focus-visible nicht aus. */
@layer base {
:where(a, button, [role="tab"], [role="button"], summary, [tabindex]):focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
}
/* Fluent-Eingabefeld: untere Kante eine Stufe dunkler; Fokus = dunklerer
Rahmen plus 2 px Akzentlinie unten. Gilt fuer Textfelder und Auswahllisten
mit dem ueblichen `border-input`/`border-border`-Rahmen; Fehlerzustaende
(aria-invalid, border-destructive) behalten ihre Farbe. Bewusst ausserhalb
eines @layer, damit die Regel die Tailwind-Utilities schlaegt. */
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive) {
border-bottom-color: var(--input-strong);
}
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive):focus {
outline: none;
border-color: var(--input-strong);
border-bottom-color: var(--primary);
box-shadow: inset 0 -1px 0 var(--primary);
}
/* Karteneffekt in einer Klasse fuer neue Flaechen: hell Schatten, dunkel Haarlinie. */
.surface {
background: var(--card);
border-radius: 0.5rem;
box-shadow: var(--shadow-sm);
}
.dark .surface {
box-shadow: none;
outline: 1px solid var(--border);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Knoepfe (Design „Mosaik“, Fluent 2): 32 px hoch, 6 px Radius, 14 px
halbfett. Primaer = Akzentflaeche mit dunkler Schrift, Sekundaer = weisse
Flaeche mit Rahmen, Subtil = ohne Flaeche. Im Komponenten-Layer, damit
einzelne Utilities (etwa `w-full`) weiter ueberschreiben koennen. */
@layer components {
.btn {
display: inline-flex;
height: 2rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.375rem;
border: 1px solid transparent;
padding-inline: 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 600;
white-space: nowrap;
transition: background-color 120ms, border-color 120ms, color 120ms;
}
.btn:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.btn:disabled {
cursor: not-allowed;
}
/* Fluent: deaktiviert = graue Flaeche, graue Schrift (nicht verblasstes Gelb). */
.btn-primary:disabled {
background-color: var(--muted);
color: var(--muted-foreground);
border-color: var(--border);
}
.btn-secondary:disabled, .btn-subtle:disabled {
color: var(--muted-foreground);
}
.btn-primary {
background-color: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover:not(:disabled) {
background-color: color-mix(in oklab, var(--primary) 88%, black);
}
.btn-secondary {
background-color: var(--card);
color: var(--foreground);
border-color: oklch(0.87 0 0);
box-shadow: 0 1px 2px oklch(0 0 0 / 0.06);
}
.btn-secondary:hover:not(:disabled) {
background-color: oklch(0.965 0 0);
}
.dark .btn-secondary {
border-color: var(--input);
box-shadow: none;
}
.dark .btn-secondary:hover:not(:disabled) {
background-color: var(--muted);
}
.btn-subtle {
background-color: transparent;
color: var(--foreground);
}
.btn-subtle:hover:not(:disabled) {
background-color: var(--muted);
}
.btn-icon {
width: 2rem;
padding-inline: 0;
}
/* Verweis: dunkle bzw. blaue Schrift, nie gelb auf Weiss. */
.link {
color: var(--link);
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
text-decoration-color: color-mix(in oklab, var(--link) 40%, transparent);
}
.link:hover {
text-decoration-color: currentColor;
}
}
/* Markdown (Notiz-Kachel, „Was ist neu“): Fluent-Schriftgroessen statt der
GitHub-Vorgaben (16 px Grundschrift, 2em-Ueberschriften), Hintergrund
transparent, Farben aus den Tokens. */
:root .wmde-markdown {
font-family: var(--font-sans);
font-size: 0.875rem;
line-height: 1.5;
background-color: transparent !important;
color: var(--foreground) !important;
--color-border-default: var(--border);
--color-border-muted: var(--border);
--color-accent-fg: var(--link);
--color-fg-default: var(--foreground);
--color-fg-muted: var(--muted-foreground);
}
:root .wmde-markdown h1 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 600; }
:root .wmde-markdown h2 { font-size: 1rem; line-height: 1.375rem; font-weight: 600; }
:root .wmde-markdown h3, :root .wmde-markdown h4 { font-size: 0.875rem; line-height: 1.25rem; font-weight: 600; }
:root .w-md-editor {
background-color: transparent !important;
box-shadow: none !important;
}
/* Navigationseintrag mit Fluent-Auswahlpille, gesteuert ueber aria-current
(Einstellungs- und Verwaltungsnavigation). */
.nav-item {
position: relative;
}
.nav-item[aria-current="page"]::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 3px;
height: 1rem;
margin-top: -0.5rem;
border-radius: 9999px;
background-color: var(--primary-strong);
}
/* 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"] {
accent-color: var(--link);
}
/* ---------------------------------------------------------------------------
Dashboard-Kacheln (Design „Mosaik“)
------------------------------------------------------------------------- */
/* Einheitliche Kachel-Kopfzeile: 40 px, 12 px Innenabstand, 14 px halbfett. */
.widget-head {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
/* Runde 3 — mehr Leben in den Kacheln, ohne bunt zu werden.
Symbol-Chip: volle Akzentfarbe als Flaeche, Symbol in der passenden Vordergrundfarbe. */
.widget-icon-chip {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 0.4375rem;
background-color: var(--primary);
color: var(--primary-foreground);
}
/* Karte hebt sich beim Zeigen leicht an (nur Maus, nur Ansicht). */
@media (hover: hover) {
.widget-card {
transition: box-shadow 180ms ease-out, translate 180ms ease-out;
}
.widget-card:not(.widget-drag-handle):hover {
translate: 0 -4px;
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 14px 30px oklch(0 0 0 / 0.18);
}
.dark .widget-card:not(.widget-drag-handle):hover {
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.12), 0 16px 34px oklch(0 0 0 / 0.6);
}
}
/* Beim Laden blenden die Kacheln gestaffelt ein (je 80 ms, --enter-delay). */
@media (prefers-reduced-motion: no-preference) {
.widget-card {
animation: widget-enter 520ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
animation-delay: var(--enter-delay, 0ms);
}
}
@keyframes widget-enter {
from {
opacity: 0;
transform: translateY(24px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
/* Aktionen der Kachel erscheinen erst beim Zeigen oder mit Tastaturfokus —
nur auf Geraeten mit Maus; auf Touch bleiben sie sichtbar. */
@media (hover: hover) {
[data-widget-id]:not(:hover):not(:focus-within) .widget-action:not(.widget-action-pinned) {
opacity: 0;
}
}
.widget-action {
transition: opacity 120ms;
}
/* Notiz: ruhige Schrift, Innenabstand wie die Kopfzeile. */
:root .widget-note-body .wmde-markdown {
font-size: 0.875rem;
line-height: 1.5;
}
:root .widget-note-body .w-md-editor-preview,
:root .widget-note-body .wmde-markdown {
padding: 0 0.75rem 0.75rem;
}
:root .widget-note-body .wmde-markdown h1,
:root .widget-note-body .wmde-markdown h2 {
border-bottom: 0;
margin-top: 0.25rem;
font-size: 1rem;
line-height: 1.375rem;
}
/* Bearbeitungsmodus: gestrichelte Akzentkontur und sichtbarer Groessengriff. */
.react-grid-item .widget-drag-handle {
outline: 2px dashed var(--primary-strong);
outline-offset: -2px;
}
.react-grid-item > .react-resizable-handle {
z-index: 20;
}
.react-grid-item > .react-resizable-handle.react-resizable-handle-se {
width: 18px;
height: 18px;
right: 4px;
bottom: 4px;
background-image: none;
border-right: 2px solid var(--primary-strong);
border-bottom: 2px solid var(--primary-strong);
border-bottom-right-radius: 4px;
}
.react-grid-item > .react-resizable-handle.react-resizable-handle-se::after {
display: none;
}
.react-grid-item.react-grid-placeholder {
background: var(--primary);
opacity: 0.18;
border-radius: 0.5rem;
}
/* Mit Hintergrund: Kacheln als Windows-11-„Mica“ — halbtransparent mit
Unschaerfe. Die einzige Stelle mit Transparenz. Deckkraft so gewaehlt,
dass Text auch vor einem rein schwarzen (hell) bzw. rein weissen, dann auf
55 % abgedunkelten (dunkel) Bild 4,5:1 haelt. Ohne backdrop-filter oder
bei „weniger Transparenz“ bleibt die Kachel deckend. */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
@media not (prefers-reduced-transparency: reduce) {
.dashboard-has-bg [data-widget-id] {
background-color: oklch(1 0 0 / 0.86);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.2);
}
.dark .dashboard-has-bg [data-widget-id] {
background-color: oklch(0.24 0.01 260 / 0.88);
border-color: oklch(1 0 0 / 0.1);
}
}
}
.dashboard-has-bg [data-widget-id] {
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
}
/* Begruessung, Befehlsleiste und leerer Zustand: ohne Hintergrund frei auf
der Arbeitsflaeche, mit Hintergrund auf derselben Kachelflaeche wie die
Widgets (deckend als Rueckfall, durchscheinend wo moeglich). */
.dashboard-has-bg .dashboard-chrome {
padding: 0.375rem 0.75rem;
background-color: var(--card);
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
}
.dashboard-has-bg [role="toolbar"].dashboard-chrome {
padding: 0.25rem;
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
@media not (prefers-reduced-transparency: reduce) {
.dashboard-has-bg .dashboard-chrome {
background-color: oklch(1 0 0 / 0.86);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.2);
}
.dark .dashboard-has-bg .dashboard-chrome {
background-color: oklch(0.24 0.01 260 / 0.88);
}
}
}
/* Notiz-Checklisten: Kaestchen in der Akzentfarbe; der Browser waehlt den
Haken selbst hell oder dunkel passend zur Akzentfarbe. */
:root .widget-note-body input[type="checkbox"] {
accent-color: var(--primary);
width: 1rem;
height: 1rem;
margin: 0 0.5rem 0 0;
vertical-align: -0.15em;
}
:root .widget-note-body .task-list-item {
padding-block: 0.125rem;
line-height: 1.5rem;
}
@@ -93,23 +93,23 @@ export function AdminSidebar() {
// 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-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
<div className="p-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<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-1 px-3" aria-label={t('admin.label')}>
<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={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${
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-medium'
: 'text-sidebar-foreground hover:bg-muted'
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={pathname.startsWith(item.href) ? 'page' : undefined}
>
@@ -20,9 +20,10 @@ export interface TesseraLogoProps {
* Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen
* Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom
* Erscheinungsbild immer hell sind (etwa das gelbe Anmelde-Panel), muss
* dies auf `false` gesetzt werden. Vorgabe: `true`.
* dies auf `false` gesetzt werden; auf immer dunklen Flaechen (App-Leiste,
* Anmelde-Panel) auf `'always'`. Vorgabe: `true`.
*/
plateOutline?: boolean;
plateOutline?: boolean | 'always';
/** Zusaetzliche Klassen fuer das aeusserste Element der gewaehlten Variante. */
className?: string;
/** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`). */
@@ -38,7 +39,7 @@ function LogoMark({
ariaLabel,
}: {
size: number;
plateOutline: boolean;
plateOutline: boolean | 'always';
className?: string;
ariaLabel: string;
}) {
@@ -61,9 +62,11 @@ function LogoMark({
fill={BRAND_PLATE}
strokeWidth={2}
className={
plateOutline
? 'stroke-transparent dark:stroke-white/25'
: 'stroke-transparent'
plateOutline === 'always'
? 'stroke-white/20'
: plateOutline
? 'stroke-transparent dark:stroke-white/25'
: 'stroke-transparent'
}
/>
{/*
@@ -34,7 +34,7 @@ export function BugReportButton() {
<button
type="button"
onClick={handleClick}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
aria-label={t('button')}
title={t('button')}
disabled={capturing}
@@ -103,7 +103,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
aria-labelledby="bug-report-title"
data-bug-report-ignore="true"
>
<div className="w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="w-full max-w-lg rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2">
{t('title')}
</h2>
@@ -115,7 +115,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
<button
type="button"
onClick={onClose}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="btn btn-primary"
>
{t('close')}
</button>
@@ -183,7 +183,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
type="button"
onClick={onClose}
disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50"
className="btn btn-secondary"
>
{t('cancel')}
</button>
@@ -191,7 +191,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
type="button"
onClick={handleSend}
disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
className="btn btn-primary"
>
{busy ? t('sending') : t('send')}
</button>
@@ -0,0 +1,276 @@
'use client';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import {
BACKGROUND_PRESETS,
type DashboardBackground,
loadDashboardBackground,
NO_BACKGROUND,
presetBackground,
saveDashboardBackground,
} from '@/lib/dashboard-background';
import {
type DashboardImageMeta,
dashboardImageSrc,
fetchDashboardImages,
uploadDashboardImage,
} from '@/lib/dashboard-images-api';
/** Wahl des Hintergrunds fuer den angemeldeten Benutzer (localStorage, Prototyp). */
export function useDashboardBackground(userId: string | null | undefined) {
const [background, setBackground] = useState<DashboardBackground>(NO_BACKGROUND);
useEffect(() => {
if (userId) setBackground(loadDashboardBackground(userId));
}, [userId]);
const choose = useCallback(
(value: DashboardBackground) => {
setBackground(value);
if (userId) saveDashboardBackground(userId, value);
},
[userId],
);
return { background, choose };
}
/** CSS fuer eine Wahl; Flaechenfarbe oder Bild, jeweils bildschirmfuellend. */
function backgroundStyle(value: DashboardBackground, dark: boolean): CSSProperties | null {
if (value.kind === 'none') return null;
if (value.kind === 'image') {
return {
backgroundImage: `url("${dashboardImageSrc(value.imageId)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
};
}
const css = presetBackground(value.id, dark);
if (!css) return null;
return css.startsWith('url(')
? { backgroundImage: css, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: css };
}
/**
* Bildschirmfuellende Hintergrundebene hinter dem Dashboard. Liegt fest
* unter Kopfzeile und Seitenleiste (beide haben eigene Flaechen). Im dunklen
* Erscheinungsbild werden eigene Bilder abgedunkelt — so bleibt der Text auf
* den durchscheinenden Kacheln auch bei einem sehr hellen Foto lesbar.
*/
export function DashboardBackdrop({ background }: { background: DashboardBackground }) {
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === 'dark';
const style = backgroundStyle(background, dark);
if (!style) return null;
return (
<div
aria-hidden="true"
data-testid="dashboard-backdrop"
data-background={background.kind === 'preset' ? background.id : background.kind}
className={`pointer-events-none fixed inset-0 z-0 ${
background.kind === 'image' ? 'dark:brightness-[0.55]' : ''
}`}
style={style}
/>
);
}
interface BackgroundPickerProps {
value: DashboardBackground;
onChange: (value: DashboardBackground) => void;
}
function sameChoice(a: DashboardBackground, b: DashboardBackground): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === 'preset' && b.kind === 'preset') return a.id === b.id;
if (a.kind === 'image' && b.kind === 'image') return a.imageId === b.imageId;
return true;
}
/**
* Knopf „Hintergrund“ (nur im Bearbeitungsmodus) mit einem kleinen Fenster:
* Vorschaubilder fuer „Keiner“, die eingebauten Hintergruende, die eigenen
* Bilder und eine Kachel zum Hochladen eines neuen Bildes.
*/
export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
const t = useTranslations('dashboard.background');
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === 'dark';
const [open, setOpen] = useState(false);
const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open || images !== null) return;
fetchDashboardImages()
.then(setImages)
.catch(() => setImages([]));
}, [open, images]);
useEffect(() => {
if (!open) return;
function onPointer(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [open]);
async function handleUpload(file: File | undefined) {
if (!file) return;
setError(null);
setUploading(true);
try {
const meta = await uploadDashboardImage(file);
setImages((prev) => [meta, ...(prev ?? [])]);
onChange({ kind: 'image', imageId: meta.id });
} catch (e) {
setError(e instanceof Error ? e.message : t('uploadError'));
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
}
const option = (
key: string,
label: string,
choice: DashboardBackground,
preview: CSSProperties,
content?: React.ReactNode,
) => {
const selected = sameChoice(value, choice);
return (
<li key={key}>
<button
type="button"
onClick={() => onChange(choice)}
aria-pressed={selected}
title={label}
className="group flex w-full flex-col items-stretch gap-1 rounded-md text-left focus-visible:outline-none"
>
<span
className={`flex h-12 items-center justify-center overflow-hidden rounded-md border text-muted-foreground transition-shadow group-focus-visible:ring-2 group-focus-visible:ring-ring ${
selected ? 'border-transparent ring-2 ring-foreground' : 'border-border group-hover:border-input-strong'
}`}
style={preview}
>
{content}
</span>
<span className={`truncate text-xs ${selected ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}>
{label}
</span>
</button>
</li>
);
};
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="dialog"
className="btn btn-subtle"
>
<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 x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</svg>
{t('button')}
</button>
{open && (
<div
role="dialog"
aria-label={t('title')}
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
>
<h2 className="text-sm font-semibold">{t('title')}</h2>
<p className="mt-0.5 text-xs text-muted-foreground">{t('hint')}</p>
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('builtIn')}</h3>
<ul className="mt-2 grid grid-cols-3 gap-3">
{option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' },
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><circle cx="12" cy="12" r="8" /><path d="m6.5 17.5 11-11" /></svg>,
)}
{BACKGROUND_PRESETS.filter((preset) => !dark || preset.dark !== null).map((preset) => {
const css = (dark ? preset.dark : preset.light) ?? preset.light;
return option(
preset.id,
t(`presets.${preset.labelKey}`),
{ kind: 'preset', id: preset.id },
css.startsWith('url(')
? { backgroundImage: css, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: css },
);
})}
</ul>
<h3 className="mt-4 text-xs font-semibold text-muted-foreground">{t('ownImages')}</h3>
<ul className="mt-2 grid max-h-40 grid-cols-3 gap-3 overflow-y-auto p-0.5">
{(images ?? []).map((image) =>
option(
image.id,
image.originalName,
{ kind: 'image', imageId: image.id },
{
backgroundImage: `url("${dashboardImageSrc(image.id)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
},
),
)}
<li>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="group flex w-full flex-col items-stretch gap-1 rounded-md text-left focus-visible:outline-none"
>
<span className="flex h-12 items-center justify-center rounded-md border border-dashed border-input-strong text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring">
{uploading ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14" /><path d="M5 12h14" /></svg>
)}
</span>
<span className="truncate text-xs text-muted-foreground">{t('upload')}</span>
</button>
</li>
</ul>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={(e) => handleUpload(e.target.files?.[0])}
/>
{images !== null && images.length === 0 && (
<p className="mt-2 text-xs text-muted-foreground">{t('noImages')}</p>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-destructive">
{error}
</p>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,59 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/stores/auth-store';
/** Tageszeit fuer die Begruessung: 5–10 Uhr Morgen, 11–17 Uhr Tag, sonst Abend. */
export function greetingPeriod(date: Date): 'morning' | 'day' | 'evening' {
const hour = date.getHours();
if (hour >= 5 && hour < 11) return 'morning';
if (hour >= 11 && hour < 18) return 'day';
return 'evening';
}
/** Vorname aus dem Anzeigenamen (erstes Wort), sonst der Benutzername. */
export function greetingName(displayName: string | null | undefined, username: string | undefined): string {
const first = displayName?.trim().split(/\s+/)[0];
return first || username || '';
}
/**
* Ruhige Begruessung (Design „Mosaik“): „Guten Morgen, Max“ plus Wochentag
* und Datum. Seit Runde 3 unten in der Seitenleiste ueber „Einklappen“. Keine Kennzahlen. Die Uhrzeit wird
* erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und
* jede Minute aufgefrischt.
*/
export function DashboardGreeting() {
const t = useTranslations('dashboard.greeting');
const locale = useLocale();
const user = useAuthStore((s) => s.user);
const [now, setNow] = useState<Date | null>(null);
useEffect(() => {
setNow(new Date());
const id = window.setInterval(() => setNow(new Date()), 60_000);
return () => window.clearInterval(id);
}, []);
if (!now) return <div className="h-9" aria-hidden="true" />;
const name = greetingName(user?.displayName, user?.username);
const period = greetingPeriod(now);
const dateLine = new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : 'de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(now);
// Runde 3: unten in der Seitenleiste statt ueber den Kacheln — zwei
// ruhige Zeilen, nimmt dem Dashboard keinen Platz mehr weg.
return (
<div data-testid="dashboard-greeting" className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">
{name ? t(`${period}Named`, { name }) : t(period)}
</p>
<p className="truncate text-xs text-muted-foreground">{dateLine}</p>
</div>
);
}
@@ -1,7 +1,8 @@
import { Children, isValidElement } from 'react';
import { act, cleanup, render, screen } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer';
import { centeringOffset, RESIZE_AXIS_FALLBACK } from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -13,6 +14,8 @@ vi.mock('next-intl', () => ({
const map: Record<string, Record<string, string>> = {
widgets: {
emptyHeading: 'No active widgets',
'emptyState.title': 'Your dashboard is still empty',
'emptyState.body': 'Build your start page from tiles.',
emptyBody: 'Click edit to add widgets to your dashboard.',
addWidget: 'Add widget',
catalogTitle: 'Add widget',
@@ -104,7 +107,33 @@ describe('DashboardGrid', () => {
/>,
);
expect(screen.getByText('No active widgets')).toBeInTheDocument();
expect(screen.getByText('Your dashboard is still empty')).toBeInTheDocument();
// Ohne Rueckrufe keine Knoepfe.
expect(screen.queryByRole('button', { name: 'Add widget' })).toBeNull();
});
it('Design „Mosaik“: leerer Zustand bietet drei Vorschlaege und den Katalog an', async () => {
const { DashboardGrid } = await import('./dashboard-grid');
const onQuickAdd = vi.fn();
const onOpenCatalog = vi.fn();
render(
<DashboardGrid
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
widgets={[]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
onQuickAdd={onQuickAdd}
onOpenCatalog={onOpenCatalog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Calendar' }));
expect(onQuickAdd).toHaveBeenCalledWith('calendar');
expect(screen.getByRole('button', { name: 'Clock' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Notes' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add widget' }));
expect(onOpenCatalog).toHaveBeenCalledTimes(1);
});
it('renders widget instance when widgets are provided', async () => {
@@ -165,7 +194,7 @@ describe('DashboardGrid', () => {
expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument();
});
it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 8, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
render(
@@ -181,7 +210,7 @@ describe('DashboardGrid', () => {
expect(captured.props).not.toBeNull();
expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 });
expect(captured.props?.rowHeight).toBe(20);
expect(captured.props?.margin).toEqual([8, 8]);
expect(captured.props?.margin).toEqual([12, 12]);
expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 });
expect(captured.props?.containerPadding).toBeUndefined();
});
@@ -409,7 +438,7 @@ describe('DashboardGrid', () => {
);
// Leerzustand: der gemessene Knoten ist gar nicht eingehaengt.
expect(screen.getByText('No active widgets')).toBeInTheDocument();
expect(screen.getByText('Your dashboard is still empty')).toBeInTheDocument();
expect(captured.props).toBeNull();
// Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef
@@ -453,3 +482,30 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1600);
});
});
describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
// Favoriten (0,0,6x10) mit einer Kachel direkt darunter (0,10,6x8).
const layout = [
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 },
{ i: 'below', x: 0, y: 10, w: 6, h: 8 },
];
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4];
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 });
});
it('laesst eine freie Groesse unveraendert', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
});
});
describe('centeringOffset (Design „Mosaik“, Runde 3)', () => {
it('stellt belegte Spalten mittig und laesst eine volle Anordnung stehen', () => {
// 24 Spalten auf 1212 px: Spalte 38 px + 12 px Abstand = 50 px je Spalte.
expect(centeringOffset([{ x: 0, w: 12 }], 24, 1212)).toBe(300);
expect(centeringOffset([{ x: 12, w: 12 }], 24, 1212)).toBe(0);
expect(centeringOffset([], 24, 1212)).toBe(0);
});
});
@@ -3,12 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry';
import { WidgetIcon } from './widgets/widget-icon';
import { WidgetWrapper } from './widgets/widget-wrapper';
import { TesseraLogo } from '@/components/brand/tessera-logo';
// quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten,
// 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen
@@ -44,6 +45,74 @@ export const WIDGET_DRAG_CANCEL_SELECTOR =
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true };
/**
* Groesse aendern trotz Nachbarn (Design „Mosaik“, Runde 3).
*
* Mit `preventCollision` verwirft react-grid-layout 2.2.3 beim Ziehen am
* Groessengriff die GANZE neue Groesse, sobald sie irgendeinen Nachbarn
* beruehrt (chunk-WGL5FSZH.mjs, onResize: w UND h zurueck auf alt). Wer eine
* Kachel nur schmaler ziehen will und dabei die Maus eine Rasterzeile nach
* unten wackelt, stoesst an die Kachel darunter — und die Breite bleibt
* stehen. Genau so liess sich die Favoriten-Kachel oft nicht schmaler machen.
*
* Diese Regel laeuft vor der Kollisionspruefung: kollidiert die gewuenschte
* Groesse, wird zuerst nur die Breite (alte Hoehe), dann nur die Hoehe (alte
* Breite) uebernommen — was frei ist, gewinnt. Nur fuer Griffe, die x/y nicht
* verschieben (se, e, s); unser Raster zeigt nur den se-Griff.
*/
function overlaps(
a: { x: number; y: number; w: number; h: number },
b: { x: number; y: number; w: number; h: number },
): boolean {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
export const RESIZE_AXIS_FALLBACK: LayoutConstraint = {
name: 'resizeAxisFallback',
constrainSize(item, w, h, handle, context) {
if (handle !== 'se' && handle !== 'e' && handle !== 's') return { w, h };
const current = context.layout.find((l) => l.i === item.i) ?? item;
const others = context.layout.filter((l) => l.i !== item.i);
const free = (cw: number, ch: number) =>
!others.some((o) => overlaps({ x: current.x, y: current.y, w: cw, h: ch }, o));
if (free(w, h)) return { w, h };
if (free(w, current.h)) return { w, h: current.h };
if (free(current.w, h)) return { w: current.w, h };
return { w, h };
},
};
const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS_FALLBACK];
const GRID_MARGIN = 12;
/**
* Seitlicher Versatz, um die belegten Spalten in der Ansicht mittig zu
* stellen (Design „Mosaik“, Runde 3): auf breiten Bildschirmen blieb rechts
* ein leerer Streifen, weil die Kacheln links beginnen. Belegt die Anordnung
* schon (fast) die ganze Breite, bleibt es bei 0.
*/
export function centeringOffset(
items: Array<{ x: number; w: number }> | undefined,
cols: number,
width: number,
): number {
if (!items || items.length === 0 || cols <= 0 || width <= 0) return 0;
const usedCols = Math.min(cols, Math.max(...items.map((l) => l.x + l.w)));
if (usedCols >= cols) return 0;
const colWidth = (width - GRID_MARGIN * (cols + 1)) / cols;
const freeWidth = (cols - usedCols) * (colWidth + GRID_MARGIN);
return Math.max(0, Math.floor(freeWidth / 2));
}
/** Breakpoint zur Breite — gleiche Regel wie react-grid-layout (strikt groesser). */
function breakpointFor(width: number): keyof typeof COLS {
const order = (Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>).sort(
(a, b) => BREAKPOINTS[b] - BREAKPOINTS[a],
);
return order.find((bp) => width > BREAKPOINTS[bp]) ?? 'xxs';
}
interface LayoutItemShape {
i: string;
x: number;
@@ -60,8 +129,17 @@ interface DashboardGridProps {
isEditMode: boolean;
onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
onRemoveWidget: (id: string) => void;
/** Leerer Zustand: oeffnet den Widget-Katalog. */
onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
onQuickAdd?: (type: WidgetType) => void;
/** Meldet den seitlichen Versatz der zentrierten Ansicht (0 im Bearbeitungsmodus). */
onInsetChange?: (px: number) => void;
}
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
/**
* quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben.
*
@@ -123,6 +201,9 @@ export function DashboardGrid({
isEditMode,
onLayoutChange,
onRemoveWidget,
onOpenCatalog,
onQuickAdd,
onInsetChange,
}: DashboardGridProps) {
const t = useTranslations('widgets');
const [width, setWidth] = useState(1200);
@@ -203,33 +284,78 @@ export function DashboardGrid({
// stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
// Empty state (D-02)
// Zentrieren nur in der Ansicht — im Bearbeitungsmodus braucht es das
// volle Raster, damit Kacheln auch in die freien Spalten rechts passen.
const breakpoint = breakpointFor(width);
const inset = isEditMode
? 0
: centeringOffset(effectiveLayouts[breakpoint] ?? effectiveLayouts.lg, COLS[breakpoint], width);
useEffect(() => {
onInsetChange?.(widgets.length === 0 ? 0 : inset);
}, [inset, onInsetChange, widgets.length]);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
// Hinzufuegen und der Hauptknopf zum Katalog.
if (widgets.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
{/* Bildmarke statt des frueheren handgezeichneten Vier-Quadrate-Symbols.
Ohne Rahmen und ohne Kontur: die Marke steht hier frei auf dem
Seitenhintergrund, nicht auf einer eigenen Flaeche. */}
<TesseraLogo variant="mark" size={64} plateOutline={false} />
<h2 className="text-lg font-semibold text-muted-foreground">
{t('emptyHeading')}
</h2>
<p className="text-sm text-muted-foreground">
{t('emptyBody')}
</p>
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<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>
</span>
<h2 className="text-base font-semibold text-foreground">{t('emptyState.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('emptyState.body')}</p>
{onQuickAdd && (
<ul className="mt-4 flex flex-wrap justify-center gap-2">
{QUICK_ADD_TYPES.map((type) => (
<li key={type}>
<button
type="button"
onClick={() => onQuickAdd(type)}
className="inline-flex h-8 items-center gap-2 rounded-full border border-border bg-card px-3 text-sm text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<WidgetIcon type={type} variant="plain" />
{t(WIDGET_REGISTRY[type].nameKey)}
</button>
</li>
))}
</ul>
)}
{onOpenCatalog && (
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
<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"><path d="M12 5v14M5 12h14" /></svg>
{t('addWidget')}
</button>
)}
</div>
</div>
);
}
return (
<div ref={measureRef}>
// overflow-x-clip: die verschobene Rasterflaeche ragt rechts ueber den
// Rand; `clip` (nicht `hidden`) laesst die Schatten oben/unten stehen.
<div ref={measureRef} className="overflow-x-clip">
<div
data-testid="dashboard-grid-inset"
className="transition-transform duration-200 ease-out motion-reduce:transition-none"
style={inset > 0 ? { transform: `translateX(${inset}px)` } : undefined}
>
<Responsive
width={width}
breakpoints={BREAKPOINTS}
cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20}
margin={[8, 8] as [number, number]}
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
constraints={GRID_CONSTRAINTS}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{
@@ -244,7 +370,7 @@ export function DashboardGrid({
compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
>
{widgets.map((widget) => {
{widgets.map((widget, index) => {
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
return (
<div
@@ -264,11 +390,13 @@ export function DashboardGrid({
widget={widget}
isEditMode={isEditMode}
onRemove={onRemoveWidget}
enterIndex={index}
/>
</div>
);
})}
</Responsive>
</div>
</div>
);
}
@@ -312,18 +312,20 @@ export function DashboardTabs({
const bar = (
<nav
aria-label={t('tabs.navLabel')}
className="flex min-w-0 max-w-full items-center gap-1.5"
className="flex h-[var(--header-height)] min-w-0 max-w-full items-center gap-1.5"
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
>
{/* Eingelassene Spur (32 px): der aktive Reiter liegt erhaben darauf. */}
<div className="h-8 min-w-0 max-w-full rounded-lg bg-muted p-0.5 shadow-[inset_0_1px_2px_oklch(0_0_0/0.08)] md:max-w-[min(56vw,720px)]">
{/* Reiter im Fluent-Stil auf der dunklen App-Leiste (Design „Mosaik“):
volle Leistenhoehe, der aktive Reiter weiss und halbfett mit 2 px
Akzentlinie an der Unterkante der Leiste. */}
<div className="h-[var(--header-height)] min-w-0 max-w-full md:max-w-[min(56vw,720px)]">
<div
ref={scrollRef}
role="tablist"
aria-describedby={dashboards.length > 1 ? hintId : undefined}
onScroll={measureOverflow}
style={maskStyle}
className="flex h-7 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="flex h-full items-stretch gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId;
@@ -339,16 +341,22 @@ export function DashboardTabs({
}}
data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`}
className={`flex h-7 shrink-0 touch-none items-center rounded-md transition-colors ${
className={`relative flex shrink-0 touch-none items-center transition-colors ${
isActive
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-card/60 hover:text-foreground'
? 'text-appbar-foreground'
: 'text-appbar-muted hover:text-appbar-foreground'
}`}
onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
{isActive && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-3 bottom-0 h-[2px] rounded-full bg-primary"
/>
)}
{isRenaming ? (
<input
ref={renameInputRef}
@@ -366,7 +374,7 @@ export function DashboardTabs({
}}
maxLength={40}
aria-label={t('tabs.renameInputLabel')}
className="h-6 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
className="h-7 w-32 rounded-md border border-white/25 bg-white/10 px-2 text-sm text-appbar-foreground outline-none focus:border-primary"
/>
) : (
<button
@@ -380,8 +388,9 @@ export function DashboardTabs({
tabIndex={isFocusable ? 0 : -1}
onClick={() => handleTabClick(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`h-7 cursor-grab whitespace-nowrap rounded-md px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isActive ? 'font-medium' : ''
data-text={tab.name}
className={`inline-flex h-8 cursor-grab flex-col items-center justify-center whitespace-nowrap rounded-md px-3 text-sm transition-colors hover:bg-appbar-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground after:invisible after:h-0 after:overflow-hidden after:font-semibold after:content-[attr(data-text)] ${
isActive ? 'font-semibold' : ''
}`}
>
{tab.name}
@@ -394,7 +403,7 @@ export function DashboardTabs({
onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')}
className="-ml-1.5 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="-ml-1.5 rounded p-1 text-appbar-muted hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
>
<svg
aria-hidden="true"
@@ -419,7 +428,7 @@ export function DashboardTabs({
onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')}
className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
className={`${isEditMode && isActive ? '' : '-ml-1.5'} mr-1 rounded p-1 text-appbar-muted hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground`}
>
<svg
aria-hidden="true"
@@ -453,7 +462,7 @@ export function DashboardTabs({
onClick={onCreate}
aria-label={t('tabs.add')}
title={t('tabs.add')}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-white/20 text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-appbar-foreground"
>
<svg
aria-hidden="true"
@@ -498,7 +507,7 @@ export function DashboardTabs({
role="alertdialog"
aria-modal="true"
aria-label={t('tabs.deleteDialogTitle')}
className="relative z-[60] mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
className="relative z-[60] mx-4 max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-xl"
>
<h3 className="mb-2 text-lg font-semibold text-foreground">
{t('tabs.deleteDialogTitle')}
@@ -510,7 +519,7 @@ export function DashboardTabs({
<button
type="button"
onClick={() => setPendingDeleteId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
@@ -21,13 +21,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<button
type="button"
onClick={onToggle}
className={`inline-flex items-center justify-center rounded-md p-2 shadow-lg transition-colors ${
isEditMode
? 'bg-primary text-primary-foreground hover:opacity-90'
: 'border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-subtle'}`}
aria-pressed={isEditMode}
aria-label={isEditMode ? t('saveChanges') : t('editMode')}
title={isEditMode ? t('saveChanges') : t('editMode')}
>
{isEditMode ? (
@@ -35,8 +30,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -46,13 +41,14 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
) : null}
{isEditMode ? null : (
// Pencil icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -64,6 +60,7 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
{isEditMode ? t('done') : t('editShort')}
</button>
);
}
@@ -74,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
@@ -50,7 +50,9 @@ export const WIDGET_CONSTRAINTS: Record<
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin.
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
// Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
@@ -313,7 +313,7 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
// Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet.
const baseBtn = 'h-full min-h-7 cursor-pointer select-none active:scale-95';
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-[clamp(10px,min(3.2cqw,3.6cqh),32px)]`;
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
// Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt.
@@ -283,6 +283,27 @@ export function formatEventDate(event: CalendarEvent): string {
}).format(start);
}
/**
* Abstand des Terminbeginns zu heute in Kalendertagen (0 = heute,
* 1 = morgen, negativ = laeuft seit gestern oder frueher). Gezaehlt wird in
* Ortszeit, nicht in 24-Stunden-Bloecken.
*/
export function eventDayOffset(event: CalendarEvent, now: Date = new Date()): number {
const start = new Date(event.start);
const a = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
const b = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((a - b) / 86_400_000);
}
/** Kurzes Datum ohne Uhrzeit, „Sa., 03.10.“. */
export function formatEventDay(event: CalendarEvent): string {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(new Date(event.start));
}
/** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
export function formatEventTime(iso: string): string {
return new Intl.DateTimeFormat('de-DE', {
@@ -169,7 +169,7 @@ describe('CalendarWidget', () => {
const badge = within(day20).getByTestId('calendar-day-count');
expect(badge).toHaveTextContent('2');
expect(badge).toHaveStyle({ backgroundColor: '#c44040' });
expect(badge).toHaveClass('text-white');
expect(badge).toHaveAttribute('data-count', '2');
expect(badge).not.toHaveClass('bg-primary');
fireEvent.mouseEnter(day20);
@@ -183,7 +183,7 @@ describe('CalendarWidget', () => {
);
});
it('Test 3c: Plakette ohne Kalenderfarbe behaelt bg-primary', async () => {
it('Test 3c: Terminpunkt ohne Kalenderfarbe traegt den Akzentton', async () => {
mockFetchEvents.mockResolvedValue([
ev('Ohne Farbe', new Date(2026, 6, 21, 9, 0), new Date(2026, 6, 21, 10, 0), {
color: undefined,
@@ -199,9 +199,9 @@ describe('CalendarWidget', () => {
const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement;
const badge = within(day21).getByTestId('calendar-day-count');
expect(badge).toHaveClass('bg-primary');
expect(badge).toHaveClass('text-primary-foreground');
expect(badge).not.toHaveClass('text-white');
// Design „Mosaik“: Terminpunkt ohne Kalenderfarbe im abgedunkelten Akzent.
expect(badge).toHaveClass('bg-primary-strong');
expect(badge).toHaveAttribute('data-count', '1');
expect((badge as HTMLElement).style.backgroundColor).toBe('');
fireEvent.mouseEnter(day21);
@@ -240,9 +240,9 @@ describe('CalendarWidget', () => {
expect(badges[1]).toHaveTextContent('2');
expect(badges[1]).toHaveStyle({ backgroundColor: '#4060c4' });
for (const badge of badges) {
expect(badge).toHaveClass('text-white');
expect(badge).toHaveClass('rounded-full');
expect(badge).not.toHaveClass('bg-primary');
expect(badge).toHaveClass('h-[clamp(8px,2.4cqw,12px)]');
expect(badge).toHaveClass('h-1');
}
expect(within(day20).queryByTestId('calendar-day-count-rest')).toBeNull();
expect(within(day20).getByTestId('calendar-day-badges').childElementCount).toBe(2);
@@ -300,7 +300,7 @@ describe('CalendarWidget', () => {
const restBadge = within(day21).getByTestId('calendar-day-count-rest');
expect(restBadge).toHaveTextContent('4');
expect(restBadge).toHaveClass('bg-muted-foreground');
expect(restBadge).toHaveClass('text-background');
expect(restBadge).toHaveAttribute('data-count', '4');
expect((restBadge as HTMLElement).style.backgroundColor).toBe('');
expect(restBadge).not.toHaveClass('text-white');
});
@@ -409,12 +409,15 @@ describe('CalendarWidget', () => {
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('E1');
expect(items[1]).toHaveTextContent('E2');
expect(items[1]).toHaveTextContent('Raum 2');
// Runde 3: einzeilige Zeile, der Ort steht im Tooltip der Zeile.
expect(items[1]).toHaveAttribute('title', expect.stringContaining('Raum 2'));
for (const item of items) {
expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument();
}
expect(items[0].textContent).toMatch(/\d{2}\.\d{2}\./);
// Runde 3: morgen steht als Wort (Akzentfarbe), spaetere Tage als Datum.
expect(items[0]).toHaveTextContent('calendar.tomorrow');
expect(items[1].textContent).toMatch(/\d{2}\.\d{2}\./);
});
it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => {
@@ -10,7 +10,9 @@ import {
buildCalendarDays,
buildDayBadges,
computeFetchWindow,
eventDayOffset,
formatEventDate,
formatEventDay,
formatEventTime,
formatMonthLabel,
groupEventsByDate,
@@ -243,21 +245,23 @@ export function CalendarWidget({ config }: WidgetProps) {
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
return (
<div className="flex h-full flex-col gap-1 overflow-hidden p-1.5">
<div className="flex h-full flex-col gap-2 overflow-hidden px-3 pb-3 pt-1">
{showMonth && (
<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">
{/* Nav-Zeile */}
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">
{/* Nav-Zeile: Pfeilknoepfe links/rechts, Monatsname mittig */}
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-1">
<button
type="button"
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted"
aria-label={t('calendar.monthPrev')}
title={t('calendar.monthPrev')}
className="widgetNoDrag flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={showPrev}
>
{t('calendar.monthPrev')}
<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="m15 18-6-6 6-6" /></svg>
</button>
<button
type="button"
className="widgetNoDrag truncate rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] font-semibold leading-none text-foreground hover:bg-muted"
className="widgetNoDrag mx-auto truncate rounded-md px-2 py-1 text-sm font-semibold leading-5 text-foreground transition-colors hover:bg-muted"
title={t('calendar.monthToday')}
onClick={showToday}
>
@@ -265,31 +269,35 @@ export function CalendarWidget({ config }: WidgetProps) {
</button>
<button
type="button"
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted"
aria-label={t('calendar.monthNext')}
title={t('calendar.monthNext')}
className="widgetNoDrag flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={showNext}
>
{t('calendar.monthNext')}
<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="m9 18 6-6-6-6" /></svg>
</button>
</div>
{/* Wochentagskopf */}
<div className="grid grid-cols-7 gap-px text-center text-[clamp(9px,2.2cqw,12px)] font-medium text-muted-foreground">
<div className="grid grid-cols-7 text-center text-[11px] font-medium leading-4 text-muted-foreground">
{WEEKDAY_LABELS.map((label) => (
<div key={label}>{label}</div>
))}
</div>
{/* Raster */}
<div className="grid grid-cols-7 gap-px">
<div className="grid grid-cols-7 gap-y-0.5">
{days.map((day) => {
const hasEvents = day.events.length > 0;
const badges = buildDayBadges(day.events);
const single = badges.length === 1;
// Tag als Kreis (Design „Mosaik“): heute gelb gefuellt, Termine
// als kleine Punkte unter der Zahl — je Kalenderquelle einer,
// in deren Farbe; ohne Farbe im Akzentton, Rest grau.
const cellClass = [
'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded bg-muted/50 px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none',
day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/60',
day.isToday ? 'ring-1 ring-primary font-semibold text-primary' : '',
hasEvents ? 'cursor-default hover:bg-muted' : '',
'widgetNoDrag relative mx-auto flex h-[clamp(20px,7cqw,28px)] w-[clamp(20px,7cqw,28px)] items-center justify-center rounded-full text-[clamp(11px,2.9cqw,13px)] leading-none tabular-nums',
day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/70',
day.isToday ? 'bg-primary font-semibold text-primary-foreground' : '',
hasEvents && !day.isToday ? 'cursor-default font-medium hover:bg-muted' : '',
]
.filter(Boolean)
.join(' ');
@@ -298,23 +306,20 @@ export function CalendarWidget({ config }: WidgetProps) {
<>
<span>{day.date.getDate()}</span>
{hasEvents && (
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht
// mehr (mehrere Kreise stehen sonst uebereinander).
<span
data-testid="calendar-day-badges"
className="absolute bottom-px right-px flex items-end gap-px"
className="absolute bottom-[3px] left-1/2 flex -translate-x-1/2 items-center gap-[2px]"
>
{badges.map((badge) => {
const badgeClass = [
'flex items-center justify-center rounded-full px-0.5 font-semibold leading-none',
single
? 'h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] text-[clamp(7px,1.8cqw,10px)]'
: 'h-[clamp(8px,2.4cqw,12px)] min-w-[clamp(8px,2.4cqw,12px)] text-[clamp(6px,1.5cqw,8px)]',
'block h-1 w-1 rounded-full',
badge.rest
? 'bg-muted-foreground text-background'
? 'bg-muted-foreground'
: badge.color
? 'text-white'
: 'bg-primary text-primary-foreground',
? ''
: day.isToday
? 'bg-primary-foreground'
: 'bg-primary-strong',
]
.filter(Boolean)
.join(' ');
@@ -322,10 +327,11 @@ export function CalendarWidget({ config }: WidgetProps) {
<span
key={badge.key}
data-testid={badge.rest ? 'calendar-day-count-rest' : 'calendar-day-count'}
data-count={badge.count}
className={badgeClass}
style={badge.color && !badge.rest ? { backgroundColor: badge.color } : undefined}
>
{badge.count}
<span className="sr-only">{badge.count}</span>
</span>
);
})}
@@ -360,7 +366,7 @@ export function CalendarWidget({ config }: WidgetProps) {
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={`${cellClass} w-full text-left`}
className={cellClass}
aria-label={ariaLabel}
onMouseEnter={(e) =>
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
@@ -395,7 +401,7 @@ export function CalendarWidget({ config }: WidgetProps) {
{maxEvents > 0 && (
<section className="flex min-h-0 flex-1 flex-col gap-1">
<h3 className="shrink-0 text-[clamp(10px,2.6cqw,13px)] font-semibold text-foreground">
<h3 className="shrink-0 text-xs font-semibold text-muted-foreground">
{t('calendar.upcomingTitle')}
</h3>
{upcoming.length === 0 ? (
@@ -403,30 +409,48 @@ export function CalendarWidget({ config }: WidgetProps) {
{t('calendar.emptyNoEvents')}
</p>
) : (
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
{upcoming.map((event) => (
<li key={event.id} className="flex items-start gap-2 rounded bg-muted/50 px-2 py-1">
<span
data-testid="event-color-dot"
className="mt-1 h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
{formatEventDate(event)}
</p>
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-semibold text-foreground">
// Einzeilige Terminliste (Design „Mosaik“, Runde 3): Farbstrich,
// Tag, Titel, Uhrzeit rechts — rund 26 px je Termin statt 40, so
// passen auch in eine kleine Kachel mehrere Termine. „Heute“ und
// „Morgen“ stehen in der Akzentfarbe.
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 overflow-y-auto">
{upcoming.map((event) => {
const offset = eventDayOffset(event);
const dayLabel =
offset === 0
? t('calendar.today')
: offset === 1
? t('calendar.tomorrow')
: formatEventDay(event);
const soon = offset === 0 || offset === 1;
return (
<li
key={event.id}
title={[formatEventDate(event), event.title, event.location].filter(Boolean).join(' · ')}
className="flex h-[26px] items-center gap-2 rounded-md px-1.5 hover:bg-muted"
>
<span
data-testid="event-color-dot"
className="h-3.5 w-[3px] shrink-0 rounded-full"
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
aria-hidden="true"
/>
<span
className={`w-[62px] shrink-0 truncate text-[11px] tabular-nums ${
soon ? 'font-semibold text-primary-strong' : 'text-muted-foreground'
}`}
>
{dayLabel}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
{event.title}
</p>
{event.location && (
<p className="truncate text-[clamp(9px,2.1cqw,12px)] text-muted-foreground">
{event.location}
</p>
)}
</div>
</li>
))}
</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
</span>
</li>
);
})}
</ul>
)}
</section>
@@ -438,7 +462,7 @@ export function CalendarWidget({ config }: WidgetProps) {
<div
data-testid="calendar-day-tooltip"
role="tooltip"
className="pointer-events-none fixed z-50 rounded border border-border bg-card p-2 text-xs text-foreground shadow-lg"
className="pointer-events-none fixed z-50 rounded bg-card dark:border dark:border-border p-2 text-xs text-foreground shadow-lg"
style={{
width: TOOLTIP_WIDTH_PX,
top: hover.rect.bottom + TOOLTIP_EDGE_PX,
@@ -1,5 +1,6 @@
'use client';
import { WidgetIcon } from './widget-icon';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { updateWidgetConfig } from '@/lib/dashboard-api';
@@ -321,7 +322,8 @@ export function FavoritesWidget({
<div className="flex h-full flex-col overflow-hidden">
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
{showHeader && (
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
<div className="widget-head">
<WidgetIcon type="favorites" />
{isEditMode ? (
<input
type="text"
@@ -341,7 +343,7 @@ export function FavoritesWidget({
</div>
)}
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}>
{/* View mode toggle (edit mode only) */}
{isEditMode && (
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
@@ -359,8 +361,8 @@ export function FavoritesWidget({
onClick={() => handleViewMode('list')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'list'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t('favorites.listView')}
@@ -370,8 +372,8 @@ export function FavoritesWidget({
onClick={() => handleViewMode('grid')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'grid'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t('favorites.gridView')}
@@ -396,10 +398,11 @@ export function FavoritesWidget({
viewMode === 'grid' ? (
<div
data-testid="favorites-grid"
className="grid grid-cols-3 gap-2"
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
launcher
key={fav.id}
fav={fav}
isEditMode={isEditMode}
@@ -429,7 +432,7 @@ export function FavoritesWidget({
) : (
<div
data-testid="favorites-list"
className="flex flex-col gap-1"
className="flex flex-col gap-0.5"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
@@ -558,10 +561,16 @@ function getDirectFaviconSrc(url: string): string | null {
function FavoriteIcon({
fav,
getFallbackLetter,
large = false,
}: {
fav: FavoriteLink;
getFallbackLetter: (title: string) => string;
/** Kachelansicht: 40-px-Feld mit 24-px-Symbol, sonst 24-px-Feld mit 16-px-Symbol. */
large?: boolean;
}) {
const box = large ? 'h-10 w-10 rounded-lg' : 'h-6 w-6 rounded-md';
const img = large ? 'h-6 w-6' : 'h-4 w-4';
const px = large ? 24 : 16;
const hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime);
const proxySrc = hasServerIcon
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
@@ -572,10 +581,10 @@ function FavoriteIcon({
);
return (
<div className="relative flex-shrink-0 w-5 h-5">
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
<span
data-testid={`letter-fallback-${fav.id}`}
className="absolute inset-0 flex items-center justify-center text-xs font-semibold bg-muted rounded"
className={`absolute inset-0 flex items-center justify-center font-semibold text-muted-foreground ${large ? 'text-base' : 'text-xs'}`}
>
{getFallbackLetter(fav.title)}
</span>
@@ -585,10 +594,10 @@ function FavoriteIcon({
src={proxySrc}
alt=""
aria-hidden="true"
width={20}
height={20}
width={px}
height={px}
loading="lazy"
className="absolute inset-0 w-5 h-5 rounded"
className={`relative rounded-sm ${img}`}
onError={() => setStage('direct')}
/>
)}
@@ -598,11 +607,11 @@ function FavoriteIcon({
src={directSrc}
alt=""
aria-hidden="true"
width={20}
height={20}
width={px}
height={px}
loading="lazy"
referrerPolicy="no-referrer"
className="absolute inset-0 w-5 h-5 rounded"
className={`relative rounded-sm ${img}`}
onError={() => setStage('none')}
/>
)}
@@ -638,6 +647,8 @@ type FavoriteTileProps = {
canMoveDown: boolean;
onMove: (id: string, direction: 'up' | 'down') => void;
t: (key: string) => string;
/** Kachelansicht wie ein App-Starter (Symbol oben, Name darunter). */
launcher?: boolean;
};
function FavoriteTile({
@@ -663,19 +674,24 @@ function FavoriteTile({
canMoveDown,
onMove,
t,
launcher = false,
}: FavoriteTileProps) {
const isEditing = editingId === fav.id;
return (
<div className="flex flex-col">
{/* Main link row */}
<div className="flex items-center gap-2">
<div className={launcher ? 'flex flex-col items-stretch gap-1' : 'flex items-center gap-2'}>
{/* Anchor — opens in new tab, no server-side redirect (T-08-08) */}
<a
href={fav.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag"
className={
launcher
? 'flex min-w-0 flex-col items-center gap-1.5 rounded-lg px-1 py-2 text-center transition-colors hover:bg-muted widgetNoDrag'
: 'flex h-9 min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 transition-colors hover:bg-muted widgetNoDrag'
}
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
>
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
@@ -683,9 +699,12 @@ function FavoriteTile({
key={`${fav.iconUrl ?? ''}|${fav.url}|${fav.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
fav={fav}
getFallbackLetter={getFallbackLetter}
large={launcher}
/>
<span className="text-xs truncate">{fav.title}</span>
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
{fav.title}
</span>
</a>
{/* Action buttons (edit mode only, D-04) */}
@@ -1,5 +1,6 @@
'use client';
import { WidgetIcon } from './widget-icon';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
@@ -163,7 +164,8 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
{/* Header: title + pencil toggle */}
<div className="relative flex items-center border-b border-border px-1.5 py-1.5 gap-2">
<div className="widget-head relative">
<WidgetIcon type="note" />
<input
type="text"
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
@@ -180,7 +182,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
<button
type="button"
onClick={() => setIsEditing((v) => !v)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-muted ${isEditing ? 'text-destructive' : 'text-muted-foreground'}`}
className={`widget-action flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-muted ${isEditing ? 'widget-action-pinned bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
title={isEditing ? t('note.viewMode') : t('note.editMode')}
>
<svg
@@ -203,7 +205,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
{/* Content: preview or editor */}
<div
className="flex-1 overflow-auto"
className="widget-note-body flex-1 overflow-auto"
data-testid="note-preview"
ref={previewRef}
>
@@ -1,5 +1,6 @@
'use client';
import { WidgetIcon } from './widget-icon';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
@@ -162,7 +163,8 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
let header: ReactNode = null;
if (isEditMode) {
header = (
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2.5 py-1.5">
<div className="widget-head">
<WidgetIcon type="proxmox" />
<input
type="text"
className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
@@ -186,7 +188,8 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
);
} else if (trimmedTitle !== '') {
header = (
<div className="shrink-0 border-b border-border px-2.5 py-1.5">
<div className="widget-head">
<WidgetIcon type="proxmox" />
<h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2>
</div>
);
@@ -113,7 +113,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
{/* Search button (right) */}
<button
type="button"
className="flex h-8 shrink-0 items-center justify-center rounded bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
className="btn btn-primary shrink-0"
onClick={handleSearch}
aria-label={t('search.searchButton')}
>
@@ -0,0 +1,97 @@
import type { ReactNode } from 'react';
/**
* Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile
* (Design „Mosaik“). Seit Runde 3 als „Chip“: 16-px-Symbol auf einer
* 26-px-Flaeche, beides aus der persoenlichen Akzentfarbe abgeleitet
* (`.widget-icon-chip` in globals.css) — bringt Farbe in die Kacheln, ohne
* bunt zu werden. `plain` ist das nackte graue Symbol (Vorschlagsknoepfe).
*/
const PATHS: Record<string, ReactNode> = {
calendar: (
<>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</>
),
note: (
<>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 13h6M10 17h6M8 13h.01M8 17h.01" />
</>
),
favorites: <path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />,
clock: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</>
),
calculator: (
<>
<rect x="4" y="2" width="16" height="20" rx="2" />
<path d="M8 6h8M16 14v4M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M8 18h.01M12 18h.01" />
</>
),
stopwatch: (
<>
<circle cx="12" cy="14" r="8" />
<path d="M10 2h4M12 14l3-3M12 2v4" />
</>
),
'picture-frame': (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</>
),
xframe: (
<>
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M2 8h20M6 5.5h.01M9 5.5h.01" />
</>
),
proxmox: (
<>
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<path d="M6 6h.01M6 18h.01" />
</>
),
};
export function WidgetIcon({ type, variant = 'chip' }: { type: string; variant?: 'chip' | 'plain' }) {
const paths = PATHS[type];
if (!paths) return null;
const svg = (
<svg
aria-hidden="true"
data-widget-icon={type}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className={variant === 'plain' ? 'shrink-0 text-muted-foreground' : 'shrink-0'}
>
{paths}
</svg>
);
if (variant === 'plain') return svg;
return (
<span aria-hidden="true" className="widget-icon-chip">
{svg}
</span>
);
}
@@ -1,7 +1,18 @@
'use client';
import type { CSSProperties } from 'react';
import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
import { WidgetIcon } from './widget-icon';
/**
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2). Notiz,
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
* ein Foto braucht keine Ueberschrift.
*/
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
interface WidgetWrapperProps {
widget: {
@@ -11,6 +22,8 @@ interface WidgetWrapperProps {
};
isEditMode: boolean;
onRemove: (id: string) => void;
/** Position im Raster — staffelt das Einblenden beim Laden (Runde 3). */
enterIndex?: number;
}
/**
@@ -25,18 +38,21 @@ interface WidgetWrapperProps {
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup.
*/
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) {
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
const cardClassName = isEditMode
? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg border border-primary/40 bg-card shadow-sm active:cursor-grabbing'
: 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm';
? 'widget-card widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
: 'widget-card relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
return (
<article
className={cardClassName}
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 80}ms` } as CSSProperties}
data-widget-id={widget.id}
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
>
@@ -45,7 +61,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
{isEditMode && (
<div
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/70"
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center"
title={t('dragHint')}
data-testid="widget-drag-head"
>
@@ -103,7 +119,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
im Bearbeitungsmodus zusaetzlich den Griff traegt.
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
<div className="@container-size h-full">
<div className="flex h-full flex-col">
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
<div className="widget-head" data-testid="widget-frame-head">
<WidgetIcon type={widget.widgetType} />
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
</div>
)}
<div className="@container-size min-h-0 flex-1">
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
@@ -122,6 +145,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
</div>
)}
</div>
</div>
</article>
);
}
@@ -84,12 +84,12 @@ describe('XframeWidget (quick-260921-qd3)', () => {
expect(link.className).toContain('absolute');
});
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre border-b), nicht absolute', () => {
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre widget-head), nicht absolute', () => {
const { container } = renderWidget({ url: URL_A, title: 'Board' });
expect(container.querySelector('h2')).toHaveTextContent('Board');
expect(frame().getAttribute('title')).toBe('Board');
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
expect(link.closest('.border-b')).not.toBeNull();
expect(link.closest('.widget-head')).not.toBeNull();
expect(link.className).not.toContain('absolute');
});
@@ -1,5 +1,6 @@
'use client';
import { WidgetIcon } from './widget-icon';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
@@ -119,12 +120,13 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
// Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
<div className="widget-head">
<WidgetIcon type="xframe" />
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
<NewTabLink
url={url}
label={openLabel}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
className="widget-action shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
/>
</div>
)}
+25 -4
View File
@@ -1,6 +1,7 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useNavStore } from '@/lib/stores/nav-store';
/**
* header.test — Sitzungswaechter im Header (quick-260917-gyd).
@@ -166,15 +167,35 @@ describe('Header — Mitte der Kopfzeile (quick-260924-h7x)', () => {
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
});
it('auf anderen Seiten: „Startseite“ bleibt wie bisher', async () => {
mockPathname = '/modules/proxmox';
vi.stubGlobal('location', { href: '', pathname: '/modules/proxmox', search: '' });
it('auf anderen Seiten: Name der aktuellen Seite statt „Startseite“ (Design „Mosaik“)', async () => {
mockPathname = '/marketplace';
vi.stubGlobal('location', { href: '', pathname: '/marketplace', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader();
expect(screen.getByText('Startseite')).toBeInTheDocument();
expect(screen.getByText('Marktplatz')).toBeInTheDocument();
expect(screen.queryByText('Startseite')).not.toBeInTheDocument();
expect(document.getElementById('header-center-slot')).not.toBeNull();
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
});
it('auf Modulseiten: Modulname aus der Seitenleiste, sonst der Ersatztitel', async () => {
mockPathname = '/modules/infrastructure/proxmox';
vi.stubGlobal('location', { href: '', pathname: mockPathname, search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader();
// Noch keine Module geladen: Ersatztitel aus den Uebersetzungen.
expect(screen.getByText('Proxmox')).toBeInTheDocument();
cleanup();
useNavStore.getState().setModules([
{ id: 'm1', slug: 'proxmox', name: 'Proxmox-Monitor', category: 'infrastructure' },
]);
await renderHeader();
expect(screen.getByText('Proxmox-Monitor')).toBeInTheDocument();
useNavStore.getState().setModules([]);
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
});
});
+37 -21
View File
@@ -12,6 +12,7 @@ import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import { usePathname } from 'next/navigation';
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
export function Header() {
const t = useTranslations();
@@ -95,14 +96,25 @@ export function Header() {
const roleLabel = user ? tHeader(`role.${user.role}`) : '';
// Seitentitel in der Mitte (Design „Mosaik“): statt immer „Startseite“
// der Name der aktuellen Seite bzw. des Moduls.
const navModules = useNavStore((s) => s.modules);
const pageTitle = resolvePageTitle(pathname, navModules);
const pageTitleText = pageTitle
? 'key' in pageTitle
? t(pageTitle.key)
: pageTitle.text
: null;
return (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm">
{/* Left: Logo + Mobile hamburger */}
<div className="flex shrink-0 items-center gap-3">
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]">
{/* Links: Menue (mobil) + Bildmarke. Dunkle App-Leiste wie bei GitHub
bzw. Microsoft 365 — in beiden Erscheinungsbildern dunkel. */}
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors md:hidden"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-foreground transition-colors md:hidden"
aria-label={t('common.menu')}
>
<svg
@@ -122,17 +134,21 @@ export function Header() {
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<TesseraLogo
variant="horizontal"
size={28}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
// Unter 640 px nur die Bildmarke: der Platz gehoert dort den
// Dashboard-Reitern in der Kopfzeilenmitte (260924-h7x).
wordmarkClassName="hidden sm:inline dark:text-primary"
/>
<Link
href="/"
className="flex items-center rounded-md px-1 py-1 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
>
<TesseraLogo
variant="horizontal"
size={26}
plateOutline="always"
className="gap-2.5"
// Schriftzug weiss auf der dunklen Leiste (in beiden
// Erscheinungsbildern). Unter 640 px nur die Bildmarke: der Platz
// gehoert dort den Dashboard-Reitern (260924-h7x).
wordmarkClassName="hidden sm:inline text-[15px] font-semibold tracking-normal text-appbar-foreground"
/>
</Link>
</div>
{/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x).
@@ -143,15 +159,15 @@ export function Header() {
und Aktionen rechts auch bei schmalem Fenster Platz behalten. */}
<div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4">
<div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
{pathname !== '/' && (
<span className="text-sm text-muted-foreground truncate">
{tHeader('breadcrumb.home')}
{pageTitleText !== null && (
<span className="truncate text-sm font-semibold text-appbar-foreground">
{pageTitleText}
</span>
)}
</div>
{/* Right: Actions */}
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-1">
<BugReportButton />
<ThemeToggle />
@@ -160,7 +176,7 @@ export function Header() {
<button
type="button"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="h-8 w-8 rounded-full overflow-hidden bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="ml-1 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-primary text-xs font-semibold text-primary-foreground ring-1 ring-white/15 transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-appbar-foreground"
aria-label={tHeader('userMenu')}
>
{user?.hasAvatar && !avatarError ? (
@@ -178,7 +194,7 @@ export function Header() {
{/* Dropdown menu */}
{dropdownOpen && (
<div className="absolute right-0 top-full mt-2 w-56 rounded-md border border-border bg-card shadow-lg py-2 z-50">
<div className="absolute right-0 top-full z-50 mt-2 w-60 rounded-lg bg-card dark:border dark:border-border py-1.5 text-card-foreground shadow-lg">
{/* User info */}
<div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-foreground truncate">
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { ModuleTile } from '@/components/modules/module-tile';
interface PageHeaderProps {
title: ReactNode;
description?: ReactNode;
/** Aktionen rechtsbuendig (Knoepfe, Verweise). */
actions?: ReactNode;
/** Modulkennung: zeigt die App-Kachel des Moduls links neben dem Titel. */
moduleSlug?: string;
className?: string;
}
/**
* Einheitlicher Seitenkopf (Design „Mosaik“, Fluent-Typografie): Titel
* 24/32 px halbfett, optionale Beschreibung 14 px gedaempft, Aktionen rechts.
* Modulseiten tragen ihre App-Kachel (36 px) links vom Titel.
*/
export function PageHeader({ title, description, actions, moduleSlug, className = '' }: PageHeaderProps) {
return (
<div className={`flex flex-wrap items-start justify-between gap-x-6 gap-y-3 ${className}`}>
<div className="flex min-w-0 items-center gap-3">
{moduleSlug && <ModuleTile slug={moduleSlug} size={36} tone="accent" />}
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">{title}</h1>
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
</div>
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
@@ -11,14 +11,32 @@ export function SidebarSearch({ value, onChange }: SidebarSearchProps) {
const t = useTranslations('sidebar');
return (
<div className="px-3 pb-2">
<div className="relative pb-2">
<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"
className="pointer-events-none absolute left-2.5 top-2 text-muted-foreground"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
{/* Gefuelltes Suchfeld ohne harten Rahmen (Fluent „filled“); Fokus =
2 px Akzentlinie unten plus Tastaturring. */}
<input
type="text"
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t('search')}
aria-label={t('search')}
className="flex h-9 w-full rounded-md border border-border bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
className="flex h-8 w-full rounded-md border-0 bg-sidebar-hover pl-8 pr-2.5 text-sm text-foreground placeholder:text-muted-foreground shadow-[inset_0_-1px_0_var(--input)] transition-shadow focus:bg-card focus:shadow-[inset_0_-2px_0_var(--primary-strong)] focus:outline-none [&::-webkit-search-cancel-button]:hidden"
/>
</div>
);
@@ -66,6 +66,10 @@ vi.mock('@/components/layout/sidebar-footer', () => ({
// Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock
// bekaeme es das Modul-Array aus `stubFetch()` (quick-260914-ku1).
vi.mock('@/components/dashboard/dashboard-greeting', () => ({
DashboardGreeting: () => <div data-testid="dashboard-greeting" />,
}));
vi.mock('@/components/layout/app-version-badge', () => ({
AppVersionBadge: () => <div data-testid="app-version-badge" />,
}));
@@ -147,12 +151,10 @@ describe('Sidebar', () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
// Categories are collapsed by default — wait for category header, then expand
// Design „Mosaik“: Kategorien sind anfangs aufgeklappt.
await waitFor(() => {
expect(screen.getByText('Domain Tools')).toBeInTheDocument();
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Domain Tools'));
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
@@ -165,13 +167,46 @@ describe('Sidebar', () => {
});
});
it('Kategorien sind aufgeklappt, ein Klick auf die Kopfzeile klappt zu und wieder auf', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const header = screen.getByText('Domain-Tools').closest('button');
expect(header).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(screen.getByText('Domain-Tools'));
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
expect(header).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(screen.getByText('Domain-Tools'));
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
it('Modul-Eintraege tragen die App-Kachel, die Auswahl die Akzent-Pille', async () => {
mockPathname = '/modules/Domain-Tools/domaincheck';
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const link = screen.getByText('Domaincheck').closest('a');
expect(link?.querySelector('[data-module-tile="domaincheck"]')).not.toBeNull();
expect(link?.querySelector('[data-testid="nav-selection-pill"]')).not.toBeNull();
const dashLink = screen.getByText('Dashboard').closest('a');
expect(dashLink?.querySelector('[data-testid="nav-selection-pill"]')).toBeNull();
});
it('re-fetches modules when sidebarRefreshKey changes', async () => {
const Sidebar = await importSidebar();
const { rerender } = render(<Sidebar />);
// Wait for category header to confirm initial fetch completed
await waitFor(() => {
expect(screen.getByText('Domain Tools')).toBeInTheDocument();
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
});
const initialCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
+152 -96
View File
@@ -9,6 +9,21 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() {
return (
<span
aria-hidden="true"
data-testid="nav-selection-pill"
className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-primary-strong"
/>
);
}
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -25,7 +40,11 @@ export function Sidebar() {
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set());
// Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
const [closedCategories, setClosedCategories] = useState<Set<string>>(new Set());
const categoryLabel = useCategoryLabel();
const setNavModules = useNavStore((s) => s.setModules);
const pathname = usePathname();
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
@@ -34,25 +53,34 @@ export function Sidebar() {
return pathname.startsWith(href);
};
const linkClass = (href: string) =>
`flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${
isActive(href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-sidebar-foreground hover:bg-muted'
// Design „Mosaik“ (Fluent 2): 36 px hohe Eintraege, 8 px Radius, die
// Auswahl traegt eine neutrale Flaeche, halbfette Schrift und links eine
// 3 x 16 px grosse Pille in der Akzentfarbe (`SelectionPill`).
const itemClass = (active: boolean) =>
`relative flex h-9 items-center gap-3 rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
isCollapsed ? 'justify-center px-0' : 'px-2.5'
} ${
active
? 'bg-sidebar-accent font-semibold text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`;
const linkClass = (href: string) => itemClass(isActive(href));
const fetchActiveModules = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (res.ok) {
setActiveModules(await res.json());
const modules: SidebarModule[] = await res.json();
setActiveModules(modules);
setNavModules(modules);
}
} catch {
// silently fail
}
}, []);
}, [setNavModules]);
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
@@ -64,7 +92,7 @@ export function Sidebar() {
}, [fetchActiveModules, sidebarRefreshKey]);
const toggleCategory = (cat: string) => {
setOpenCategories((prev) => {
setClosedCategories((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
@@ -85,7 +113,7 @@ export function Sidebar() {
const q = searchQuery.toLowerCase();
const result: [string, SidebarModule[]][] = [];
for (const [cat, mods] of categories.entries()) {
if (cat.toLowerCase().includes(q)) {
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
result.push([cat, mods]);
} else {
const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
@@ -93,115 +121,144 @@ export function Sidebar() {
}
}
return result;
}, [activeModules, searchQuery]);
}, [activeModules, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
{children}
</svg>
);
const sidebarContent = (
<div className="flex h-full flex-col bg-sidebar">
<nav className="flex-1 overflow-y-auto p-3">
<ul className="flex flex-col gap-1">
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className="flex flex-col gap-0.5">
<li>
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
{isActive('/') && <SelectionPill />}
{navIcon(
<>
<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" />
</>,
)}
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
</Link>
</li>
<li>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
{isActive('/marketplace') && <SelectionPill />}
{navIcon(
<>
<path d="M3 9h18l-1.5 10.5a2 2 0 0 1-2 1.5h-11a2 2 0 0 1-2-1.5z" />
<path d="M8 9V7a4 4 0 0 1 8 0v2" />
</>,
)}
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
</Link>
</li>
</ul>
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
{isCollapsed && activeModules.length > 0 && (
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
{activeModules.map((mod) => {
const href = `/modules/${mod.category || 'other'}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
</Link>
</li>
);
})}
</ul>
)}
{!isCollapsed && (
<>
<div className="mt-4">
<div className="mt-4 border-t border-sidebar-border pt-3">
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
</div>
<div className="mt-2">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('categories.label')}
</div>
<div className="mt-1">
{activeModules.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
) : filteredCategories.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground">
{t('noResults')}
</div>
) : (
<ul className="flex flex-col gap-1">
{filteredCategories.map(([category, mods]) => (
<div className="mt-1" aria-label={t('categories.label')} role="group">
{activeModules.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
) : filteredCategories.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('noResults')}
</div>
) : (
<ul className="flex flex-col gap-2">
{filteredCategories.map(([category, mods]) => {
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
const open = searchQuery !== '' || !closedCategories.has(category);
return (
<li key={category}>
<button
type="button"
onClick={() => toggleCategory(category)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
isActive(`/modules/${category}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
aria-expanded={open}
className="group flex h-7 w-full items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<span className="truncate">{categoryLabel(category)}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
<span className="ml-auto flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/60">{mods.length}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${openCategories.has(category) ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
{openCategories.has(category) && (
<ul className="ml-5 flex flex-col gap-0.5">
{mods.map((mod) => (
<li key={mod.id}>
<Link
href={`/modules/${category}/${mod.slug}`}
className={`block rounded-md px-2 py-1 text-xs transition-colors ${
isActive(`/modules/${category}/${mod.slug}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{mod.name}
</Link>
</li>
))}
{open && (
<ul className="mt-0.5 flex flex-col gap-0.5">
{mods.map((mod) => {
const href = `/modules/${category}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
<span className="truncate">{mod.name}</span>
</Link>
</li>
);
})}
</ul>
)}
</li>
))}
</ul>
)}
</div>
);
})}
</ul>
)}
</div>
</>
)}
</nav>
<div className="hidden md:block border-t border-sidebar-border p-2">
{/* Begruessung (Design „Mosaik“, Runde 3): im sonst leeren unteren
Bereich der Leiste statt ueber den Kacheln; eingeklappt entfaellt sie. */}
{!isCollapsed && (
<div className="border-t border-sidebar-border px-5 py-2.5">
<DashboardGreeting />
</div>
)}
<div className="hidden border-t border-sidebar-border px-3 py-2 md:block">
<button
type="button"
onClick={toggle}
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
className={`flex h-8 w-full items-center gap-3 rounded-lg text-sm text-muted-foreground transition-colors hover:bg-sidebar-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${isCollapsed ? 'justify-center' : 'px-2.5'}`}
aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : undefined}
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<polyline points="15 18 9 12 15 6" />
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
</svg>
{!isCollapsed && <span className="truncate">{t('collapse')}</span>}
</button>
@@ -210,7 +267,7 @@ export function Sidebar() {
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
{!isCollapsed && (
<div className="border-t border-sidebar-border px-4 py-2">
<div className="px-5 pb-3 pt-1">
<AppVersionBadge />
</div>
)}
@@ -220,7 +277,7 @@ export function Sidebar() {
return (
<>
<aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-all duration-200 md:block"
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
style={{
width: isCollapsed
? 'var(--sidebar-width-collapsed)'
@@ -231,28 +288,27 @@ export function Sidebar() {
</aside>
{isMobileOpen && (
<div className="fixed inset-0 z-40 md:hidden">
<div className="fixed inset-0 z-[60] md:hidden">
<div
className="fixed inset-0 bg-black/50"
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg">
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4">
<aside className="fixed left-0 top-0 z-50 flex h-full w-[var(--sidebar-width)] flex-col bg-sidebar shadow-xl">
{/* Kopf der Schublade im Stil der App-Leiste (immer dunkel). */}
<div className="flex h-[var(--header-height)] items-center justify-between bg-appbar px-3 text-appbar-foreground">
<TesseraLogo
variant="horizontal"
size={28}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
wordmarkClassName="dark:text-primary"
/>
variant="horizontal"
size={26}
plateOutline="always"
className="gap-2.5"
wordmarkClassName="text-[15px] font-semibold tracking-normal text-appbar-foreground"
/>
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label={tCommon('close')}
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
@@ -260,7 +316,7 @@ export function Sidebar() {
</svg>
</button>
</div>
{sidebarContent}
<div className="min-h-0 flex-1">{sidebarContent}</div>
</aside>
</div>
)}
@@ -20,16 +20,16 @@ export function ModuleAccessDenied({ title, body, backToDashboard }: ModuleAcces
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
@@ -38,9 +38,9 @@ export function ModuleAccessDenied({ title, body, backToDashboard }: ModuleAcces
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">{title}</h2>
<h2 className="mb-1 text-base font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground mb-6">{body}</p>
<Link href="/" className="text-sm font-medium text-primary hover:underline">
<Link href="/" className="btn btn-primary">
{backToDashboard}
</Link>
</div>
@@ -0,0 +1,106 @@
import type { ReactNode } from 'react';
import { getModuleIcon, type ModuleIconId } from '@/lib/module-identity';
const GLYPHS: Record<ModuleIconId, ReactNode> = {
radar: (
<>
<path d="M19.07 4.93A10 10 0 0 0 6.99 3.34" />
<path d="M4 6h.01" />
<path d="M2.29 9.62A10 10 0 1 0 21.31 8.35" />
<path d="M16.24 7.76A6 6 0 1 0 8.23 16.67" />
<path d="M12 18h.01" />
<path d="M17.99 11.66A6 6 0 0 1 15.77 16.67" />
<circle cx="12" cy="12" r="2" />
<path d="m13.41 10.59 5.66-5.66" />
</>
),
fuel: (
<>
<line x1="3" x2="15" y1="22" y2="22" />
<line x1="4" x2="14" y1="9" y2="9" />
<path d="M14 22V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v18" />
<path d="M14 13h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2a2 2 0 0 0 2-2V9.83a2 2 0 0 0-.59-1.42L18 5" />
</>
),
certificate: (
<>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</>
),
globe: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</>
),
server: (
<>
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</>
),
tile: (
<>
<path d="M21 8a2 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.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</>
),
};
interface ModuleTileProps {
slug: string;
/** Kantenlaenge in Pixeln (Seitenleiste 20, Karten 40, Seitenkopf 36). */
size?: number;
/**
* `neutral` (Vorgabe): graue Flaeche, dunkelgraues Symbol.
* `accent`: Akzentflaeche mit passender Schrift — fuer das Modul im Fokus.
*/
tone?: 'neutral' | 'accent';
className?: string;
}
/**
* App-Kachel eines Moduls: abgerundetes Quadrat mit Symbol (siehe
* lib/module-identity.ts). Kontrast Symbol/Flaeche: neutral hell 7,4:1,
* dunkel 8,9:1; Akzent ueber `--primary-foreground`, das fuer jede
* persoenliche Akzentfarbe hell oder dunkel gewaehlt wird (lib/color.ts).
* Rein dekorativ — der Modulname steht immer als Text daneben.
*/
export function ModuleTile({ slug, size = 20, tone = 'neutral', className = '' }: ModuleTileProps) {
const icon = getModuleIcon(slug);
const glyph = Math.round(size * 0.6);
return (
<span
aria-hidden="true"
data-module-tile={slug}
data-tone={tone}
className={`inline-flex shrink-0 items-center justify-center ${
tone === 'accent' ? 'bg-primary text-primary-foreground' : 'bg-tile text-tile-foreground'
} ${className}`}
style={{
width: size,
height: size,
borderRadius: Math.max(4, Math.round(size * 0.24)),
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={glyph}
height={glyph}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={size <= 24 ? 2.2 : 1.9}
strokeLinecap="round"
strokeLinejoin="round"
>
{GLYPHS[icon]}
</svg>
</span>
);
}
@@ -7,7 +7,8 @@ import type { MeterLevel, ServerHealth } from './proxmox-status';
*
* `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` =
* zweischichtiger Schatten, der untere im Statuston getoent.
* ruhender Fluent-Schatten (Design „Mosaik“: keine im Statuston
* leuchtenden Schatten mehr — der Zustand steht in Leiste und Pille).
*
* Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel.
*/
@@ -19,32 +20,31 @@ export const HEALTH_STYLE: Record<
fill: 'bg-status-ok',
pill: 'bg-status-ok/12 text-status-ok-fg',
text: 'text-status-ok-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-ok)]',
shadow: 'shadow-sm',
},
warn: {
fill: 'bg-status-warn',
pill: 'bg-status-warn/12 text-status-warn-fg',
text: 'text-status-warn-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-warn)]',
shadow: 'shadow-sm',
},
down: {
fill: 'bg-status-down',
pill: 'bg-status-down/12 text-status-down-fg',
text: 'text-status-down-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-down)]',
shadow: 'shadow-sm',
},
idle: {
fill: 'bg-status-idle',
pill: 'bg-status-idle/12 text-status-idle-fg',
text: 'text-status-idle-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.06),0_12px_28px_-14px_var(--status-idle)]',
shadow: 'shadow-sm',
},
// Kein farbiger Schatten: ein verwaister Server soll nicht „leuchten“.
orphan: {
fill: 'bg-status-orphan',
pill: 'bg-status-orphan/12 text-status-orphan-fg',
text: 'text-status-orphan-fg',
shadow: 'shadow-[0_1px_2px_oklch(0_0_0/0.04)]',
shadow: 'shadow-sm',
},
};
@@ -64,4 +64,4 @@ export const METER_TEXT: Record<MeterLevel, string> = {
};
/** Eingelassenes Messfeld („Well“). */
export const WELL = 'rounded-lg bg-well shadow-[inset_0_1px_2px_oklch(0_0_0/0.06)]';
export const WELL = 'rounded-lg bg-well';
@@ -127,7 +127,7 @@ export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProp
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative z-50 mx-4 flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-xl"
className="relative z-50 mx-4 flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg bg-card dark:border dark:border-border shadow-xl"
>
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
<h2
@@ -215,7 +215,7 @@ export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProp
<button
type="button"
onClick={close}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="btn btn-primary"
>
{t('confirm')}
</button>
@@ -197,7 +197,7 @@ export function AccountSettingsForm() {
disabled={isAvatarPending}
onChange={handleAvatarUpload}
/>
<span className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50">
<span className="btn btn-primary">
{isAvatarPending ? '...' : t('account.uploadCta')}
</span>
</label>
@@ -281,7 +281,7 @@ export function AccountSettingsForm() {
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending || !isHexValid}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
className="btn btn-primary"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
@@ -365,7 +365,7 @@ export function AccountSettingsForm() {
<button
type="submit"
disabled={isPwPending}
className="flex items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
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">
@@ -358,7 +358,7 @@ export function CalendarSettingsPanel() {
<button
type="button"
onClick={() => setShowAddForm(true)}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
className="btn btn-primary"
>
{t('calendar.addSource')}
</button>
@@ -394,7 +394,7 @@ export function CalendarSettingsPanel() {
/>
<div
className="relative mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
className="relative mx-4 max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-xl"
role="alertdialog"
aria-modal="true"
aria-label={t('calendar.deleteDialogLabel')}
@@ -409,7 +409,7 @@ export function CalendarSettingsPanel() {
<button
type="button"
onClick={() => setDeletingId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
className="btn btn-secondary"
>
{ts('cancel')}
</button>
@@ -355,7 +355,7 @@ export function CalendarSourceForm({
type="submit"
data-testid="source-form-submit"
disabled={!isFormValid || isSaving}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-primary"
>
{isSaving ? t('calendar.formSaving') : t('calendar.formSave')}
</button>
@@ -364,14 +364,14 @@ export function CalendarSourceForm({
data-testid="source-form-test"
disabled={!isFormValid || testStatus === 'loading'}
onClick={handleTest}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
className="btn btn-secondary"
>
{testStatus === 'loading' ? t('calendar.formTesting') : t('calendar.formTestConnection')}
</button>
<button
type="button"
onClick={onCancel}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
className="btn btn-secondary"
>
{t('calendar.formCancel')}
</button>

Some files were not shown because too many files have changed in this diff Show More