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 Link from 'next/link';
import { login } from '@/lib/auth-actions'; import { login } from '@/lib/auth-actions';
import { sanitizeNextPath } from '@/lib/safe-next'; import { sanitizeNextPath } from '@/lib/safe-next';
import { BRAND_YELLOW } from '@/components/brand/brand';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links'; import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
/** /**
* Split-screen login page (D-01). * Split-screen login page (D-01), Design „Mosaik“.
* Left: Tessera branding on the brand yellow background (BRAND_YELLOW). * Left (ab lg): dunkles Markenpanel mit Bildmarke, Claim und Farbmosaik.
* Right: Login form on neutral background. * Right: Anmeldekarte auf der Arbeitsflaeche; unter lg nur die Karte.
* No sidebar or header (D-04, handled by (auth) layout). * No sidebar or header (D-04, handled by (auth) layout).
*/ */
export default function LoginPage() { export default function LoginPage() {
@@ -42,41 +41,52 @@ export default function LoginPage() {
} }
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen bg-background">
{/* Left: Branding panel (hidden on mobile, visible on md+) */} {/* Links (ab lg): dunkles Markenpanel im Ton der App-Leiste mit
<div grosser Bildmarke, Produktname, einem ruhigen Satz und einem
className="hidden md:flex md:flex-1 items-center justify-center" stillen Mosaik in Grau und Gelb — kein Verlauf, keine Animation. */}
style={{ backgroundColor: BRAND_YELLOW }} <div className="relative hidden w-[45%] shrink-0 flex-col overflow-hidden bg-appbar px-14 py-12 text-appbar-foreground lg:flex">
> <TesseraLogo
<div className="px-8 text-gray-900"> variant="horizontal"
<TesseraLogo size={40}
variant="horizontal" plateOutline="always"
size={104} className="gap-3"
plateOutline={false} wordmarkClassName="text-xl font-semibold tracking-normal text-appbar-foreground"
tagline={t('branding.tagline')} />
className="gap-6"
wordmarkClassName="text-5xl" <div className="my-auto max-w-[30rem]">
taglineClassName="text-lg text-gray-800 max-w-xs" <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>
</div> </div>
{/* Right: Login form */} {/* Rechts: Anmeldekarte zentriert auf der Arbeitsflaeche */}
<div className="flex flex-1 items-center justify-center bg-background px-4 py-8"> <div className="flex flex-1 items-center justify-center px-4 py-10">
<div className="w-full max-w-sm space-y-8"> <div className="w-full max-w-[400px]">
{/* Mobile-only branding */} {/* Unter lg: Bildmarke ueber der Karte */}
<div className="text-center md:hidden"> <div className="mb-6 flex justify-center lg:hidden">
<TesseraLogo variant="horizontal" size={32} /> <TesseraLogo
<p className="mt-2 text-sm text-muted-foreground"> variant="horizontal"
{t('branding.tagline')} size={32}
</p> className="gap-2.5"
wordmarkClassName="text-lg font-semibold tracking-normal"
/>
</div> </div>
<div className="surface space-y-6 p-6 sm:p-8">
{/* Login heading */} {/* Login heading */}
<div className="text-center"> <div>
<h2 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl font-semibold text-foreground">
{t('login')} {t('login')}
</h2> </h1>
<p className="mt-1 text-sm text-muted-foreground">
{t('loginSubtitle')}
</p>
</div> </div>
{/* Error message */} {/* Error message */}
@@ -87,12 +97,12 @@ export default function LoginPage() {
)} )}
{/* Login form */} {/* Login form */}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Username */} {/* Username */}
<div className="space-y-2"> <div className="space-y-1.5">
<label <label
htmlFor="username" htmlFor="username"
className="text-sm font-medium text-foreground" className="block text-sm font-semibold text-foreground"
> >
{t('username')} {t('username')}
</label> </label>
@@ -102,16 +112,15 @@ export default function LoginPage() {
type="text" type="text"
required required
autoComplete="username" 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" className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
placeholder={t('username')}
/> />
</div> </div>
{/* Password */} {/* Password */}
<div className="space-y-2"> <div className="space-y-1.5">
<label <label
htmlFor="password" htmlFor="password"
className="text-sm font-medium text-foreground" className="block text-sm font-semibold text-foreground"
> >
{t('password')} {t('password')}
</label> </label>
@@ -121,33 +130,30 @@ export default function LoginPage() {
type="password" type="password"
required required
autoComplete="current-password" 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" className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
placeholder={t('password')}
/> />
</div> </div>
{/* Remember me (D-02) */} {/* Angemeldet bleiben (D-02) und Passwort vergessen (D-03) in einer Zeile */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<input <input
id="rememberMe" id="rememberMe"
name="rememberMe" name="rememberMe"
type="checkbox" type="checkbox"
defaultChecked defaultChecked
className="h-4 w-4 rounded border-input text-primary focus:ring-ring" className="h-4 w-4 rounded border-input"
/> />
<label <label
htmlFor="rememberMe" htmlFor="rememberMe"
className="text-sm text-muted-foreground" className="whitespace-nowrap text-sm text-muted-foreground"
> >
{t('rememberMe')} {t('rememberMe')}
</label> </label>
</div> </div>
{/* Forgot password link (D-03) */}
<div className="flex justify-end">
<Link <Link
href="/reset-password" href="/reset-password"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="whitespace-nowrap text-sm link"
> >
{t('forgotPassword')} {t('forgotPassword')}
</Link> </Link>
@@ -157,7 +163,7 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={isPending} 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 ? ( {isPending ? (
<svg <svg
@@ -186,11 +192,49 @@ export default function LoginPage() {
)} )}
</button> </button>
</form> </form>
</div>
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */} {/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
<DesktopDownloadLinks /> <div className="mt-6">
<DesktopDownloadLinks />
</div>
</div> </div>
</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 */} {/* Heading */}
<div className="text-center"> <div className="text-center">
<TesseraLogo variant="horizontal" size={32} /> <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')} {t('resetPassword.newPasswordTitle')}
</h2> </h2>
</div> </div>
@@ -99,7 +99,7 @@ export default function ResetPasswordTokenPage() {
</p> </p>
<Link <Link
href="/login" 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')} {t('resetPassword.backToLogin')}
</Link> </Link>
@@ -164,7 +164,7 @@ export default function ResetPasswordTokenPage() {
<button <button
type="submit" type="submit"
disabled={isPending} 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 ? ( {isPending ? (
<svg <svg
@@ -54,7 +54,7 @@ export default function ResetPasswordPage() {
{/* Heading */} {/* Heading */}
<div className="text-center"> <div className="text-center">
<TesseraLogo variant="horizontal" size={32} /> <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')} {t('resetPassword.title')}
</h2> </h2>
</div> </div>
@@ -67,7 +67,7 @@ export default function ResetPasswordPage() {
</div> </div>
<Link <Link
href="/login" 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')} {t('resetPassword.backToLogin')}
</Link> </Link>
@@ -104,7 +104,7 @@ export default function ResetPasswordPage() {
<button <button
type="submit" type="submit"
disabled={isPending} 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 ? ( {isPending ? (
<svg <svg
@@ -88,7 +88,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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> <h2 className="text-lg font-semibold text-foreground mb-4">{t('deleteConfirm.title')}</h2>
{loadingImpact ? ( {loadingImpact ? (
@@ -116,7 +116,7 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
<button <button
type="button" type="button"
onClick={onClose} 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')} {tCommon('cancel')}
</button> </button>
@@ -77,7 +77,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <h2 className="text-lg font-semibold text-foreground mb-4">
{group ? t('edit') : t('create')} {group ? t('edit') : t('create')}
</h2> </h2>
@@ -101,7 +101,7 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
{!group && ( {!group && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('createLdapHint')}{' '} {t('createLdapHint')}{' '}
<Link href="/admin/ldap" className="text-primary hover:underline"> <Link href="/admin/ldap" className="link">
{t('goToLdap')} {t('goToLdap')}
</Link> </Link>
</p> </p>
@@ -139,14 +139,14 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
<button <button
type="button" type="button"
onClick={onClose} 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')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
disabled={saving} 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')} {saving ? tCommon('loading') : tCommon('save')}
</button> </button>
@@ -158,7 +158,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <h2 className="text-lg font-semibold text-foreground mb-4">
{t('members.title')} — {group.name} {t('members.title')} — {group.name}
</h2> </h2>
@@ -252,7 +252,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
<button <button
type="button" type="button"
onClick={onClose} 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')} {tCommon('close')}
</button> </button>
@@ -260,7 +260,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
type="button" type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={adding || selectedUserIds.length === 0} 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})`} {adding ? tCommon('loading') : `${t('members.addTitle')} (${selectedUserIds.length})`}
</button> </button>
@@ -124,11 +124,11 @@ export default function AdminGroupsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <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 <button
type="button" type="button"
onClick={openCreate} 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')} {t('create')}
</button> </button>
@@ -149,7 +149,7 @@ export default function AdminGroupsPage() {
<button <button
type="button" type="button"
onClick={openCreate} 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')} {t('create')}
</button> </button>
+2 -2
View File
@@ -12,9 +12,9 @@ export default function AdminLayout({
const t = useTranslations('settings'); const t = useTranslations('settings');
return ( return (
<div className="flex h-full"> <div className="flex h-full flex-col md:flex-row">
<AdminSidebar /> <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 <Link
href="/" href="/"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" 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 ( return (
<div className="space-y-8"> <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 1: Connection Settings */}
<section className="rounded-lg border border-border p-6"> <section className="rounded-lg border border-border p-6">
@@ -646,7 +646,7 @@ export default function AdminLdapPage() {
<button <button
type="submit" type="submit"
disabled={saving} 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')} {saving ? tCommon('loading') : t('save')}
</button> </button>
@@ -654,7 +654,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={!config && !formData.serverUrl} 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')} {t('testConnection')}
</button> </button>
@@ -816,7 +816,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleDiscoverGroups} onClick={handleDiscoverGroups}
disabled={discovering} 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')} {discovering ? tCommon('loading') : t('groupFilter.discover')}
</button> </button>
@@ -919,7 +919,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSaveGroupFilter} onClick={handleSaveGroupFilter}
disabled={savingFilter} 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')} {savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button> </button>
@@ -941,7 +941,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleDiscoverGroupsToImport} onClick={handleDiscoverGroupsToImport}
disabled={discoveringGroupImport} 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 {discoveringGroupImport
? tCommon('loading') ? tCommon('loading')
@@ -1015,7 +1015,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleImportGroups} onClick={handleImportGroups}
disabled={importingGroups || selectedImportDns.length === 0} 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 {importingGroups
? tCommon('loading') ? tCommon('loading')
@@ -1142,7 +1142,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={() => handleImportUsers(selectedUserDns)} onClick={() => handleImportUsers(selectedUserDns)}
disabled={importingUsers || selectedUserDns.length === 0} 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 {importingUsers
? tCommon('loading') ? tCommon('loading')
@@ -1234,7 +1234,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSaveExcludeList} onClick={handleSaveExcludeList}
disabled={savingExclude} 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')} {savingExclude ? tCommon('loading') : t('userExclude.save')}
</button> </button>
@@ -1297,7 +1297,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saving} 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')} {t('save')}
</button> </button>
@@ -1319,7 +1319,7 @@ export default function AdminLdapPage() {
type="button" type="button"
onClick={handleSync} onClick={handleSync}
disabled={syncing} 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')} {syncing ? t('sync.syncing') : t('sync.trigger')}
</button> </button>
@@ -142,7 +142,7 @@ export function ActivateModuleDialog({
role="dialog" role="dialog"
aria-modal="true" 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"> <h2 className="mb-2 break-words text-lg font-semibold text-foreground">
{t('title', { module: moduleName })} {t('title', { module: moduleName })}
</h2> </h2>
@@ -157,7 +157,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} 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')} {t('cancel')}
</button> </button>
@@ -165,7 +165,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={handleConfigureLater} onClick={handleConfigureLater}
disabled={isSubmitting} 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')} {t('configureLater')}
</button> </button>
@@ -173,7 +173,7 @@ export function ActivateModuleDialog({
type="button" type="button"
onClick={handleGrantNow} onClick={handleGrantNow}
disabled={grantNowDisabled} 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')} {t('grantNow')}
</button> </button>
@@ -189,7 +189,7 @@ export default function AdminModuleGrantsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div> </div>
{error && ( {error && (
@@ -205,7 +205,7 @@ export default function AdminModuleGrantsPage() {
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p> <p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link <Link
href="/admin/modules" 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')} {t('emptyModulesLink')}
</Link> </Link>
@@ -134,12 +134,12 @@ export default function AdminModulesPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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> <p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div> </div>
<Link <Link
href="/admin/modules/grants" 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')} {t('grantsLink')}
</Link> </Link>
@@ -146,11 +146,11 @@ export default function AdminTenantsPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <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 <button
type="button" type="button"
onClick={openCreate} 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')} {t('create')}
</button> </button>
@@ -244,7 +244,7 @@ export default function AdminTenantsPage() {
{/* Create/Edit modal */} {/* Create/Edit modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <h2 className="text-lg font-bold text-foreground mb-4">
{editingTenant ? t('edit') : t('create')} {editingTenant ? t('edit') : t('create')}
</h2> </h2>
@@ -287,13 +287,13 @@ export default function AdminTenantsPage() {
<button <button
type="button" type="button"
onClick={() => setShowForm(false)} 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')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" 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')} {tCommon('save')}
</button> </button>
@@ -306,7 +306,7 @@ export default function AdminTenantsPage() {
{/* Delete confirmation modal */} {/* Delete confirmation modal */}
{deleteConfirm && ( {deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <p className="text-sm text-foreground mb-4">
{t('deleteConfirm')} {t('deleteConfirm')}
</p> </p>
@@ -314,7 +314,7 @@ export default function AdminTenantsPage() {
<button <button
type="button" type="button"
onClick={() => setDeleteConfirm(null)} 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')} {tCommon('cancel')}
</button> </button>
@@ -128,7 +128,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
role="dialog" role="dialog"
aria-modal="true" 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"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2> <h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2>
<button <button
@@ -227,11 +227,11 @@ export default function AdminUsersPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between"> <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 <button
type="button" type="button"
onClick={openCreate} 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')} {t('create')}
</button> </button>
@@ -349,7 +349,7 @@ export default function AdminUsersPage() {
{/* Create/Edit modal */} {/* Create/Edit modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <h2 className="text-lg font-bold text-foreground mb-4">
{editingUser ? t('edit') : t('create')} {editingUser ? t('edit') : t('create')}
</h2> </h2>
@@ -455,13 +455,13 @@ export default function AdminUsersPage() {
<button <button
type="button" type="button"
onClick={() => setShowForm(false)} 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')} {tCommon('cancel')}
</button> </button>
<button <button
type="submit" 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')} {tCommon('save')}
</button> </button>
@@ -474,7 +474,7 @@ export default function AdminUsersPage() {
{/* Delete confirmation modal */} {/* Delete confirmation modal */}
{deleteConfirm && ( {deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <p className="text-sm text-foreground mb-4">
{t('deleteConfirm')} {t('deleteConfirm')}
</p> </p>
@@ -490,7 +490,7 @@ export default function AdminUsersPage() {
<button <button
type="button" type="button"
onClick={() => setDeleteConfirm(null)} 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')} {tCommon('cancel')}
</button> </button>
@@ -47,7 +47,7 @@ export default function ChangePasswordPage() {
return ( return (
<div className="mx-auto max-w-md py-8 px-4"> <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')} {t('changePassword.title')}
</h1> </h1>
@@ -129,7 +129,7 @@ export default function ChangePasswordPage() {
<button <button
type="submit" type="submit"
disabled={isPending} 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 ? ( {isPending ? (
<svg <svg
+4 -4
View File
@@ -1,4 +1,5 @@
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PageHeader } from '@/components/layout/page-header';
import { ChangelogView } from '@/components/changelog/changelog-view'; import { ChangelogView } from '@/components/changelog/changelog-view';
import { appVersion } from '@/lib/app-version'; import { appVersion } from '@/lib/app-version';
import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog'; import { changelogMarkdown, filterChangelogForChannel } from '@/lib/changelog';
@@ -22,14 +23,13 @@ export default async function ChangelogPage() {
}); });
return ( return (
<div className="mx-auto max-w-3xl py-8 px-4"> <div className="mx-auto max-w-3xl p-3 sm:p-6">
<h1 className="text-2xl font-bold text-foreground mb-2">{t('title')}</h1> <PageHeader title={t('title')} description={t('intro')} className="mb-6" />
<p className="text-sm text-muted-foreground mb-6">{t('intro')}</p>
{hasUnreleased && ( {hasUnreleased && (
<div <div
data-testid="changelog-unreleased-hint" 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')} {t('unreleasedHint')}
</div> </div>
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; 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 { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useToastStore } from '../components/Toast'; import { useToastStore } from '../components/Toast';
@@ -32,6 +34,7 @@ interface CatalogModule {
export default function ModuleDetailPage() { export default function ModuleDetailPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const t = useTranslations('marketplace'); const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore(); const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
const addToast = useToastStore((s) => s.addToast); const addToast = useToastStore((s) => s.addToast);
@@ -148,7 +151,7 @@ export default function ModuleDetailPage() {
const isActive = module.isActiveForTenant; const isActive = module.isActiveForTenant;
return ( 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"> <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"> <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" /> <path d="m15 18-6-6 6-6" />
@@ -157,23 +160,11 @@ export default function ModuleDetailPage() {
</Link> </Link>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="rounded-lg bg-muted p-4 shrink-0"> <ModuleTile slug={module.slug} size={56} />
{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>
<div> <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"> <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> <span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span>
</div> </div>
</div> </div>
@@ -190,11 +181,7 @@ export default function ModuleDetailPage() {
type="button" type="button"
onClick={handleToggle} onClick={handleToggle}
disabled={toggling} disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${ className={`btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
> >
{toggling ? ( {toggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true"> <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> <h2 className="text-lg font-semibold text-foreground mb-2">{t('deactivateDialogTitle')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
{t('deactivateDialogBody', { moduleName })} {t('deactivateDialogBody', { moduleName })}
@@ -55,7 +55,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
type="button" type="button"
ref={cancelRef} ref={cancelRef}
onClick={onCancel} 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')} {t('deactivateDialogCancel')}
</button> </button>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCategoryLabel } from '@/lib/use-category-label';
interface CategoryFilterProps { interface CategoryFilterProps {
categories: string[]; categories: string[];
@@ -10,17 +11,23 @@ interface CategoryFilterProps {
export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) { export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) {
const t = useTranslations('marketplace'); 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 ( 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 <button
type="button" type="button"
onClick={() => onChange('all')} onClick={() => onChange('all')}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${ aria-pressed={value === 'all'}
value === 'all' className={chip(value === 'all')}
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
> >
{t('categoryAll')} {t('categoryAll')}
</button> </button>
@@ -29,13 +36,10 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
key={cat} key={cat}
type="button" type="button"
onClick={() => onChange(cat)} onClick={() => onChange(cat)}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${ aria-pressed={value === cat}
value === cat className={chip(value === cat)}
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
> >
{cat} {categoryLabel(cat)}
</button> </button>
))} ))}
</div> </div>
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
/** /**
* Props for the MarketplaceCard component. * Props for the MarketplaceCard component.
@@ -25,53 +27,6 @@ export interface MarketplaceCardProps {
onLockedClick: () => void; 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. * Marketplace card showing a module with activation controls.
* *
@@ -84,7 +39,6 @@ export function MarketplaceCard({
name, name,
slug, slug,
description, description,
icon,
category, category,
isActive, isActive,
hasAccess, hasAccess,
@@ -95,6 +49,7 @@ export function MarketplaceCard({
onLockedClick, onLockedClick,
}: MarketplaceCardProps) { }: MarketplaceCardProps) {
const t = useTranslations('marketplace'); const t = useTranslations('marketplace');
const categoryLabel = useCategoryLabel();
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'; const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || ''; const localizedDescription = description[locale] || description.en || description.de || '';
@@ -114,10 +69,9 @@ export function MarketplaceCard({
return ( return (
<div <div
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${ 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' : 'hover:shadow-md hover:border-primary/30' locked ? 'opacity-60' : isActive ? 'hover:shadow-md' : ''
}`} }`}
style={{ minHeight: '180px' }}
> >
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte {/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */} Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
@@ -126,54 +80,47 @@ export function MarketplaceCard({
type="button" type="button"
onClick={handleCardClick} onClick={handleCardClick}
aria-label={t('openDetail', { name })} 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 */} {/* Kopf: App-Kachel + Name + Kategorie */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3">
<div className="rounded-md bg-muted p-2.5 shrink-0"> <ModuleTile slug={slug} size={40} />
<ModuleIcon icon={icon} />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3> <h3 className="truncate text-base font-semibold leading-snug text-foreground">{name}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1"> <p className="truncate text-xs text-muted-foreground">{categoryLabel(category)}</p>
<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>
</div> </div>
</div> </div>
{/* Description */} {/* Beschreibung */}
<p className="text-sm text-muted-foreground line-clamp-2 mb-4"> <p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{localizedDescription} {localizedDescription}
</p> </p>
{/* Footer: action button — only rendered for users who may activate/deactivate */} {/* Fuss: Status links, Aktion rechts */}
{canManage && ( <div className="mt-auto flex items-center justify-between gap-2 pt-4">
<div className="relative flex items-center justify-end"> <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 <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@@ -181,11 +128,7 @@ export function MarketplaceCard({
onToggle(id, isActive); onToggle(id, isActive);
}} }}
disabled={isToggling} disabled={isToggling}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${ className={`relative btn disabled:cursor-wait ${isActive ? 'btn-secondary' : 'btn-primary'}`}
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
> >
{isToggling ? ( {isToggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <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') t('activate')
)} )}
</button> </button>
</div> )}
)} </div>
</div> </div>
); );
} }
@@ -23,13 +23,30 @@ export function MarketplaceSearch({ onChange }: MarketplaceSearchProps) {
}, [value, onChange]); }, [value, onChange]);
return ( 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 <input
type="text" type="text"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder={t('searchPlaceholder')} 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 && ( {value && (
<button <button
@@ -21,25 +21,37 @@ export function StatusFilter({ value, counts, onChange }: StatusFilterProps) {
return ( return (
<div className="flex gap-1 border-b border-border" role="tablist"> <div className="flex gap-1 border-b border-border" role="tablist">
{tabs.map((tab) => ( {tabs.map((tab) => {
<button const selected = value === tab.key;
key={tab.key} return (
type="button" <button
role="tab" key={tab.key}
aria-selected={value === tab.key} type="button"
onClick={() => onChange(tab.key)} role="tab"
className={`px-3 py-2 text-sm font-medium transition-colors ${ aria-selected={selected}
value === tab.key onClick={() => onChange(tab.key)}
? 'text-foreground border-b-2 border-primary' 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 ${
: 'text-muted-foreground hover:text-foreground' selected ? 'font-semibold text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t(tab.label)} {t(tab.label)}
<span className="ml-1.5 text-xs text-muted-foreground"> <span
{counts[tab.key]} className={`ml-2 rounded-full px-1.5 text-xs font-medium ${
</span> selected ? 'bg-foreground/10 text-foreground' : 'bg-muted text-muted-foreground'
</button> }`}
))} >
{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> </div>
); );
} }
@@ -37,8 +37,8 @@ export function TenantContextSelector() {
if (!isSuperAdmin) return null; if (!isSuperAdmin) return null;
return ( return (
<div className="bg-muted/50 rounded-lg p-3 mb-4 border border-border"> <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="block text-xs font-semibold text-muted-foreground mb-1.5"> <label htmlFor="tenant-context-select" className="text-sm font-semibold text-foreground">
{t('tenantContextLabel')} {t('tenantContextLabel')}
</label> </label>
{loading ? ( {loading ? (
@@ -48,7 +48,7 @@ export function TenantContextSelector() {
id="tenant-context-select" id="tenant-context-select"
onChange={(e) => setSelectedTenantId(e.target.value || null)} onChange={(e) => setSelectedTenantId(e.target.value || null)}
defaultValue="" 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> <option value="">—</option>
{tenants.map((tenant) => ( {tenants.map((tenant) => (
@@ -39,7 +39,7 @@ export function ToastContainer() {
{toasts.map((toast) => ( {toasts.map((toast) => (
<div <div
key={toast.id} 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' ? ( {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"> <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 { StatusFilter, type StatusValue } from './components/StatusFilter';
import { CategoryFilter } from './components/CategoryFilter'; import { CategoryFilter } from './components/CategoryFilter';
import { ToastContainer, useToastStore } from './components/Toast'; import { ToastContainer, useToastStore } from './components/Toast';
import { PageHeader } from '@/components/layout/page-header';
import { TenantContextSelector } from './components/TenantContextSelector'; import { TenantContextSelector } from './components/TenantContextSelector';
import { ActivationDialog } from './components/ActivationDialog'; import { ActivationDialog } from './components/ActivationDialog';
@@ -179,13 +180,10 @@ export default function MarketplacePage() {
}; };
return ( return (
<div className="space-y-6"> <div className="mx-auto max-w-7xl space-y-6 p-3 sm:p-6">
<TenantContextSelector /> <PageHeader title={t('title')} description={t('subtitle')} />
<div> <TenantContextSelector />
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
</div>
{error && ( {error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <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> </div>
) : modules.length === 0 ? ( ) : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center"> <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 <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="64" width="28"
height="64" height="28"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1" strokeWidth="1.75"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="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="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="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" /> <path d="M12 22V12" />
</svg> </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> <p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
</div> </div>
) : ( ) : (
<> <>
<div className="space-y-4"> <div className="space-y-3">
<MarketplaceSearch onChange={setSearchQuery} /> <MarketplaceSearch onChange={setSearchQuery} />
<StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} /> <StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} />
<CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} /> <CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} />
@@ -229,7 +228,13 @@ export default function MarketplacePage() {
{filteredModules.length === 0 && hasFiltersApplied ? ( {filteredModules.length === 0 && hasFiltersApplied ? (
<div className="flex flex-col items-center justify-center min-h-[30vh] text-center"> <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> <p className="mt-1 text-sm text-muted-foreground max-w-md">{t('filteredEmptyBody')}</p>
</div> </div>
) : ( ) : (
@@ -38,16 +38,16 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
return ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <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="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 <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="48" width="28"
height="48" height="28"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.75"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-muted-foreground" className="text-muted-foreground"
@@ -57,13 +57,13 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
<path d="m9 9 6 6" /> <path d="m9 9 6 6" />
</svg> </svg>
</div> </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"> <p className="text-sm text-muted-foreground mb-6">
{t('notFoundDescription')} {t('notFoundDescription')}
</p> </p>
<Link <Link
href={`/modules/${category}`} href={`/modules/${category}`}
className="text-sm font-medium text-primary hover:underline" className="btn btn-secondary"
> >
{t('backToCategory')} {t('backToCategory')}
</Link> </Link>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { ModuleTile } from '@/components/modules/module-tile';
import Link from 'next/link'; import Link from 'next/link';
/** /**
@@ -14,54 +15,6 @@ export interface ModuleCardProps {
category: string; 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. * 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, * to the expanded module view. Per D-05a: uniform card appearance,
* compact, looks good even with a single module. * 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 t = useTranslations('modules');
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'; const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.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}`} href={`/modules/${category}/${slug}`}
className="group block" 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 */} {/* Header: icon + name */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0"> <ModuleTile slug={slug} size={40} />
<ModuleIcon icon={icon} />
</div>
<div className="min-w-0"> <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} {name}
</h3> </h3>
</div> </div>
@@ -99,7 +50,7 @@ export function ModuleCard({ name, slug, description, icon, category }: ModuleCa
{/* Footer: open link */} {/* Footer: open link */}
<div className="flex justify-end"> <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')} {t('openModule')}
</span> </span>
</div> </div>
@@ -95,7 +95,7 @@ export default function CategoryPage() {
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{t('categoryTitle', { category: categoryTitle })} {t('categoryTitle', { category: categoryTitle })}
</h1> </h1>
</div> </div>
@@ -76,7 +76,7 @@ export function ConvertTab({ file, pemText, password, onTargetFormatChange }: Co
type="button" type="button"
onClick={handleConvert} onClick={handleConvert}
disabled={loading || !canConvert} 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')} {loading ? t('actions.processing') : t('actions.convert')}
</button> </button>
@@ -46,7 +46,7 @@ export function InspectTab({ file, pemText, password }: InspectTabProps) {
type="button" type="button"
onClick={handleInspect} onClick={handleInspect}
disabled={loading || !canInspect} 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')} {loading ? t('actions.processing') : t('actions.inspect')}
</button> </button>
@@ -139,7 +139,7 @@ export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
data-testid="merge-action-btn" data-testid="merge-action-btn"
onClick={handleMerge} onClick={handleMerge}
disabled={loading || !canMerge} 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')} {loading ? t('actions.processing') : t('actions.merge')}
</button> </button>
@@ -82,7 +82,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
type="button" type="button"
onClick={handleSplit} onClick={handleSplit}
disabled={loading || !file} 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')} {loading ? t('actions.processing') : t('actions.split')}
</button> </button>
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { DropZone } from './components/DropZone'; import { DropZone } from './components/DropZone';
@@ -98,15 +99,11 @@ export default function CertManagerPage() {
}; };
return ( return (
<div className="mx-auto max-w-4xl space-y-6 p-6"> <div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
{/* Header */} <PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
<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>
{/* Shared input card */} {/* 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 */}
<DropZone <DropZone
onFile={handleFile} onFile={handleFile}
@@ -148,7 +145,7 @@ export default function CertManagerPage() {
onClick={() => handleTabChange(tab)} onClick={() => handleTabChange(tab)}
className={`pb-2 text-sm font-medium transition-colors ${ className={`pb-2 text-sm font-medium transition-colors ${
activeTab === tab 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' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
@@ -158,7 +155,7 @@ export default function CertManagerPage() {
</nav> </nav>
{/* Tab content card */} {/* 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()} {renderActiveTab()}
</div> </div>
</div> </div>
@@ -61,7 +61,7 @@ export function ExportFileList({ historyItems }: ExportFileListProps) {
<a <a
href={exportFileUrl(filename)} href={exportFileUrl(filename)}
download download
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{filename} {filename}
</a> </a>
@@ -89,7 +89,7 @@ export function InvoiceHistoryTable({
const totalPages = Math.ceil(total / 25); const totalPages = Math.ceil(total / 25);
const theadCls = 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 ( return (
<div className="rounded border border-border overflow-hidden"> <div className="rounded border border-border overflow-hidden">
@@ -170,7 +170,7 @@ export function InvoiceHistoryTable({
<a <a
href={exportFileUrl(row.exportFilename)} href={exportFileUrl(row.exportFilename)}
download 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 /> <DownloadIcon />
{row.exportFilename} {row.exportFilename}
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api'; import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api';
import { InvoiceHistoryTable } from './components/InvoiceHistoryTable'; import { InvoiceHistoryTable } from './components/InvoiceHistoryTable';
import { ExportFileList } from './components/ExportFileList'; import { ExportFileList } from './components/ExportFileList';
@@ -75,51 +76,47 @@ export default function DkvFleetPage() {
}; };
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-6"> <div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
{/* Page Header Row */} {/* Seitenkopf (Design „Mosaik“): App-Kachel, Titel, Stand; Aktionen rechts */}
<div className="flex items-start justify-between gap-4"> <PageHeader
<div> moduleSlug="dkv-fleet"
<h1 className="text-2xl font-semibold tracking-tight"> title={t('pageTitle')}
{t('pageTitle')} description={
</h1> lastChecked
<p className="mt-1 text-sm text-muted-foreground"> ? t('lastChecked', { time: formatLastChecked(lastChecked) })
{lastChecked : t('neverChecked')
? t('lastChecked', { time: formatLastChecked(lastChecked) }) }
: t('neverChecked')} actions={
</p> <>
</div> <Link
href={`${basePath}/settings`}
{/* Einstellungen-Link */} className="btn btn-secondary btn-icon"
<Link title={t('settingsTitle')}
href={`${basePath}/settings`} aria-label={t('settingsTitle')}
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">
>
<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" /> <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" /> <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> </svg>
</Link> </Link>
<button
{/* "Jetzt prüfen" button per 07-UI-SPEC button states */} type="button"
<button onClick={handleCheckNow}
type="button" disabled={isChecking}
onClick={handleCheckNow} className="btn btn-primary"
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 ? (
isChecking ? ' opacity-80 cursor-not-allowed' : '' <>
}`} <SpinnerIcon />
> {t('checking')}
{isChecking ? ( </>
<> ) : (
<SpinnerIcon /> t('checkNow')
{t('checking')} )}
</> </button>
) : ( </>
t('checkNow') }
)} />
</button>
</div>
{/* Error banner — conditional per UI-SPEC */} {/* Error banner — conditional per UI-SPEC */}
{pollError && ( {pollError && (
@@ -103,14 +103,14 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button <button
type="button" type="button"
onClick={handleButtonClick} 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')} {t('importCsv')}
</button> </button>
{/* Import mode inline dialog (below button, per UI-SPEC) */} {/* Import mode inline dialog (below button, per UI-SPEC) */}
{showDialog && selectedFile && ( {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"> <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>{t('csvFormatHelp')}</p>
<p className="font-mono">{t('csvFormatExample')}</p> <p className="font-mono">{t('csvFormatExample')}</p>
@@ -185,7 +185,7 @@ export function CsvImportButton({ onImported }: CsvImportButtonProps) {
<button <button
type="button" type="button"
onClick={handleCancel} 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')} {t('form.cancelDialog')}
</button> </button>
@@ -525,7 +525,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={isTesting || isSaving} 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')} {isTesting ? t('form.testTesting') : t('form.testConnection')}
</button> </button>
@@ -533,7 +533,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || isTesting} 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')} {t('form.save')}
</button> </button>
@@ -542,7 +542,7 @@ export function InboxConfigForm() {
type="button" type="button"
onClick={handleCheckNow} onClick={handleCheckNow}
disabled={isChecking || isSaving || isTesting} 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'} {isChecking ? 'Prüfe...' : 'Jetzt prüfen'}
</button> </button>
@@ -77,7 +77,7 @@ const vehicleToEdit = (v: DkvVehicle): EditState => ({
// --- Shared classes --- // --- Shared classes ---
const theadCls = 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 = const iconBtnCls =
'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors'; 'p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors';
const editInputCls = const editInputCls =
@@ -95,7 +95,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
const t = useTranslations('dkvFleet'); const t = useTranslations('dkvFleet');
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <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"> <h3 className="text-base font-semibold text-foreground mb-2">
{t('deleteVehicle')} {t('deleteVehicle')}
</h3> </h3>
@@ -107,7 +107,7 @@ function DeleteDialog({ plate, isDeleting, onConfirm, onCancel }: DeleteDialogPr
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isDeleting} 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')} {t('form.cancelDialog')}
</button> </button>
@@ -132,7 +132,7 @@ interface ToastProps {
function Toast({ message }: ToastProps) { function Toast({ message }: ToastProps) {
return ( 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} {message}
</div> </div>
); );
@@ -317,7 +317,7 @@ export function VehicleTable() {
type="button" type="button"
onClick={startAdd} onClick={startAdd}
disabled={isAddingNew} 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')} {t('addVehicle')}
</button> </button>
@@ -408,7 +408,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')} aria-label={t('form.saveRow')}
onClick={() => saveEdit(v.id)} onClick={() => saveEdit(v.id)}
disabled={isSavingRow} disabled={isSavingRow}
className={`${iconBtnCls} hover:text-primary`} className={`${iconBtnCls} hover:text-foreground`}
> >
<CheckIcon /> <CheckIcon />
</button> </button>
@@ -515,7 +515,7 @@ export function VehicleTable() {
aria-label={t('form.saveRow')} aria-label={t('form.saveRow')}
onClick={saveNew} onClick={saveNew}
disabled={isSavingNew} disabled={isSavingNew}
className={`${iconBtnCls} hover:text-primary`} className={`${iconBtnCls} hover:text-foreground`}
> >
<CheckIcon /> <CheckIcon />
</button> </button>
@@ -24,7 +24,7 @@ export default function DkvFleetSettingsPage() {
const tabCls = (tab: ActiveTab) => const tabCls = (tab: ActiveTab) =>
`px-4 py-2 text-sm transition-colors ${ `px-4 py-2 text-sm transition-colors ${
activeTab === tab 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' : 'text-muted-foreground hover:text-foreground'
}`; }`;
@@ -33,7 +33,7 @@ export function DomainInput({ onSubmit, isLoading }: DomainInputProps) {
<button <button
type="submit" type="submit"
disabled={isLoading || !domain.trim()} 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')} {isLoading ? t('checking') : t('checkButton')}
</button> </button>
@@ -33,7 +33,7 @@ export function ResultList({ results }: ResultListProps) {
{suggestions.length > 0 && ( {suggestions.length > 0 && (
<div className="space-y-2"> <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')} {t('suggestions')}
</p> </p>
{suggestions.map((result) => ( {suggestions.map((result) => (
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { PageHeader } from '@/components/layout/page-header';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { type DomainCheckResult, checkDomainAction } from './actions'; import { type DomainCheckResult, checkDomainAction } from './actions';
@@ -37,17 +38,11 @@ export default function DomaincheckPage() {
}; };
return ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <div className="mx-auto max-w-2xl space-y-6 p-3 sm:p-6">
{/* Header */} <PageHeader moduleSlug="domaincheck" title={t('title')} description={t('description')} />
<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>
{/* Card panel (per D-05a) */} {/* 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 */} {/* Domain input */}
<DomainInput onSubmit={handleSubmit} isLoading={isLoading} /> <DomainInput onSubmit={handleSubmit} isLoading={isLoading} />
@@ -443,8 +443,8 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
data-testid="server-card" data-testid="server-card"
data-health={health} data-health={health}
aria-label={`${server.name}: ${t(`health.${health}`)}`} aria-label={`${server.name}: ${t(`health.${health}`)}`}
className={`relative flex h-full flex-col overflow-hidden rounded-xl border bg-card ${ className={`relative flex h-full flex-col overflow-hidden rounded-lg bg-card ${
isOrphan ? 'border-dashed border-border' : 'border-border' isOrphan ? 'border border-dashed border-border' : 'dark:border dark:border-border'
} ${style.shadow}`} } ${style.shadow}`}
> >
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} /> <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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api'; import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from '@/components/proxmox/HealthBar'; import { HealthBar } from '@/components/proxmox/HealthBar';
@@ -36,7 +37,7 @@ function RefreshIcon({ spinning }: { spinning: boolean }) {
function SkeletonCard() { function SkeletonCard() {
return ( 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="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-muted" /> <div className="h-8 w-8 rounded-lg bg-muted" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
@@ -106,33 +107,31 @@ export default function ProxmoxPage() {
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6"> <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"> <PageHeader
<div className="min-w-0"> moduleSlug="proxmox"
<h1 className="text-2xl font-semibold tracking-tight">{t('title')}</h1> title={t('title')}
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p> description={t('description')}
</div> actions={
{isAdmin && ( isAdmin ? (
<div className="flex items-center gap-4"> <>
<Link <Link href="/modules/proxmox/settings" className="btn btn-subtle">
href="/modules/proxmox/settings" {t('card.settingsNav')}
className="text-sm text-muted-foreground transition-colors hover:text-foreground" </Link>
> {servers !== null && servers.length > 0 && (
{t('card.settingsNav')} <button
</Link> type="button"
{servers !== null && servers.length > 0 && ( onClick={handleRefresh}
<button disabled={isRefreshing}
type="button" className="btn btn-primary"
onClick={handleRefresh} >
disabled={isRefreshing} <RefreshIcon spinning={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" {isRefreshing ? t('card.refreshing') : t('card.refresh')}
> </button>
<RefreshIcon spinning={isRefreshing} /> )}
{isRefreshing ? t('card.refreshing') : t('card.refresh')} </>
</button> ) : undefined
)} }
</div> />
)}
</div>
{error && ( {error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground"> <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 ( 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> <div>
<label htmlFor="proxmox-name" className={labelCls}> <label htmlFor="proxmox-name" className={labelCls}>
{t('settings.nameLabel')} {t('settings.nameLabel')}
@@ -368,7 +368,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || !form.name || !form.baseUrl} 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')} {isSaving ? t('settings.saving') : t('settings.save')}
</button> </button>
@@ -383,7 +383,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="button" type="button"
onClick={handleTest} onClick={handleTest}
disabled={isTesting || !form.baseUrl} 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')} {isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button> </button>
@@ -17,7 +17,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
const t = useTranslations('proxmox'); const t = useTranslations('proxmox');
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <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"> <h3 className="mb-2 text-base font-semibold text-foreground">
{t('settings.deleteConfirmTitle')} {t('settings.deleteConfirmTitle')}
</h3> </h3>
@@ -29,7 +29,7 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isDeleting} 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')} {t('settings.deleteCancelButton')}
</button> </button>
@@ -119,7 +119,7 @@ export default function ProxmoxSettingsPage() {
<button <button
type="button" type="button"
onClick={() => setEditingId('new')} 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')} {t('settings.addServer')}
</button> </button>
@@ -155,7 +155,7 @@ export default function ProxmoxSettingsPage() {
) : ( ) : (
<li <li
key={server.id} 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>
<div className="font-medium">{server.name}</div> <div className="font-medium">{server.name}</div>
@@ -167,7 +167,7 @@ export default function ProxmoxSettingsPage() {
<button <button
type="button" type="button"
onClick={() => setEditingId(server.id)} 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')} {t('settings.edit')}
</button> </button>
@@ -157,7 +157,7 @@ export function FilterPanel() {
const labelCls = 'mb-1 block text-sm text-foreground'; const labelCls = 'mb-1 block text-sm text-foreground';
return ( 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="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1"> <div className="min-w-[220px] flex-1">
<label htmlFor="tr-filter-q" className={labelCls}> <label htmlFor="tr-filter-q" className={labelCls}>
@@ -297,7 +297,7 @@ export function FilterPanel() {
autoComplete="off" autoComplete="off"
/> />
{cpvMatches.length > 0 && ( {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) => ( {cpvMatches.map((d) => (
<li key={d.code}> <li key={d.code}>
<button <button
@@ -65,7 +65,7 @@ export function PollNowButton({ onPolled }: PollNowButtonProps) {
onClick={handlePoll} onClick={handlePoll}
disabled={isPolling} disabled={isPolling}
title={t('page.pollNowTitle')} 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' : '' 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 totalPages = Math.max(1, Math.ceil(total / limit));
const thStaticCls = 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`; const thSortCls = `${thStaticCls} cursor-pointer select-none hover:text-foreground transition-colors`;
return ( return (
@@ -219,7 +219,7 @@ export function SavedSearchBar() {
}; };
return ( 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> <h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
@@ -276,7 +276,7 @@ export function SavedSearchBar() {
type="button" type="button"
aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })} aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
onClick={() => confirmRename(profile.id)} onClick={() => confirmRename(profile.id)}
className="text-xs font-medium text-primary hover:underline" className="text-xs font-medium link"
> >
{t('savedSearch.save')} {t('savedSearch.save')}
</button> </button>
@@ -67,7 +67,7 @@ function formatPublished(value: string): string {
} }
function fieldLabelCls() { 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} href={source.sourceUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" 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) })} {t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })}
</a> </a>
@@ -251,7 +251,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
href={tender.sourceUrl} href={tender.sourceUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" 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')} {t('detail.sourceLinkFallback')}
</a> </a>
@@ -76,7 +76,7 @@ export default function TenderRadarMySourcesPage() {
<div className="mt-8 border-t border-border pt-6"> <div className="mt-8 border-t border-border pt-6">
<Link <Link
href="/modules/tender-radar/settings" href="/modules/tender-radar/settings"
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{t('page.settingsTitle')} &rarr; {t('page.settingsTitle')} &rarr;
</Link> </Link>
@@ -4,6 +4,7 @@ import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { CoverageBanner } from './components/CoverageBanner'; import { CoverageBanner } from './components/CoverageBanner';
import { FilterPanel } from './components/FilterPanel'; import { FilterPanel } from './components/FilterPanel';
import { PollNowButton } from './components/PollNowButton'; import { PollNowButton } from './components/PollNowButton';
@@ -59,42 +60,41 @@ function TenderRadarContent() {
}; };
return ( return (
<div className="mx-auto max-w-5xl space-y-6 p-6"> <div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<div className="flex items-start justify-between gap-4"> <PageHeader
<h1 className="text-2xl font-bold tracking-tight"> moduleSlug="tender-radar"
{t('page.title')} title={t('page.title')}
</h1> actions={
<>
{/* 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
* Zahnrad-Link. Absolute literal href (NOT the dynamic * exist as literal paths — the dynamic settings route returns
* [category]/[moduleSlug] basePath): tender-radar routes only * "Modul nicht gefunden" for this module, so the gear must point
* exist as literal paths — the dynamic settings route returns * at the literal path directly.
* "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
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the * (not just administrators) is allowed to configure something
* admin-only settings page — that's the page every module user * on; administrators reach the platform-wide settings from there
* (not just administrators) is allowed to configure something * with one more click.
* on; administrators reach the platform-wide settings from there */}
* with one more click. <Link
*/} href="/modules/tender-radar/my-sources"
<Link className="btn btn-secondary btn-icon"
href="/modules/tender-radar/my-sources" title={t('page.mySourcesTitle')}
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" aria-label={t('page.mySourcesTitle')}
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">
>
<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" /> <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" /> <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> </svg>
</Link> </Link>
<PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} /> <PollNowButton onPolled={() => setRefreshKey((k) => k + 1)} />
</div> </>
</div> }
/>
<CoverageBanner /> <CoverageBanner />
<SavedSearchBar /> <SavedSearchBar />
@@ -449,7 +449,7 @@ export function EmailAlertConfigForm() {
type="button" type="button"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={isTesting || isSaving} 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')} {isTesting ? t('emailAlerts.testTesting') : t('emailAlerts.testConnection')}
</button> </button>
@@ -457,7 +457,7 @@ export function EmailAlertConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving || isTesting} 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')} {isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button> </button>
@@ -230,7 +230,7 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
type="button" type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={isAdding} 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')} {isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')}
</button> </button>
@@ -216,7 +216,7 @@ export function SourceConfigForm() {
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={isSaving} 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')} {isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')}
</button> </button>
@@ -71,7 +71,7 @@ export default function TenderRadarSettingsPage() {
</p> </p>
<Link <Link
href="/modules/tender-radar/my-sources" href="/modules/tender-radar/my-sources"
className="text-sm text-primary hover:underline" className="text-sm link"
> >
{t('mySources.title')} &rarr; {t('mySources.title')} &rarr;
</Link> </Link>
+18 -12
View File
@@ -11,12 +11,16 @@ const messages: Record<string, Record<string, string>> = {
editMode: 'Dashboard bearbeiten', editMode: 'Dashboard bearbeiten',
saveChanges: 'Änderungen speichern', saveChanges: 'Änderungen speichern',
addWidget: 'Widget hinzufügen', addWidget: 'Widget hinzufügen',
editShort: 'Bearbeiten',
done: 'Fertig',
toolbarLabel: 'Dashboard-Aktionen',
layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.', layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
}, },
}; };
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key, useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key,
useLocale: () => 'de',
})); }));
const mockStore = { const mockStore = {
@@ -106,45 +110,47 @@ afterEach(() => {
}); });
describe('DashboardPage (quick-260916-dyv)', () => { 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'); const { default: DashboardPage } = await import('./page');
render(<DashboardPage />); render(<DashboardPage />);
const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' }); const toolbar = screen.getByRole('toolbar', { name: 'Dashboard-Aktionen' });
const bar = toggle.parentElement as HTMLElement; const toggle = screen.getByRole('button', { name: 'Bearbeiten' });
expect(bar.classList.contains('fixed')).toBe(true); expect(toggle.parentElement).toBe(toolbar);
expect(bar.classList.contains('bottom-6')).toBe(true); expect(toggle).toHaveAttribute('aria-pressed', 'false');
expect(bar.classList.contains('right-6')).toBe(true); // kein schwebender Knopf mehr unten rechts
expect(bar.classList.contains('z-20')).toBe(true); expect(toolbar.classList.contains('fixed')).toBe(false);
expect(document.querySelector('.bottom-6')).toBeNull();
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
expect(document.querySelector('.mt-8')).toBeNull(); expect(document.querySelector('.mt-8')).toBeNull();
expect(document.querySelector('.top-2')).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'); const grid = screen.getByTestId('dashboard-grid');
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true); expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
expect(grid.getAttribute('data-edit')).toBe('false'); 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; mockStore.isEditMode = true;
const { default: DashboardPage } = await import('./page'); const { default: DashboardPage } = await import('./page');
render(<DashboardPage />); 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' }); const add = screen.getByRole('button', { name: 'Widget hinzufügen' });
expect(add.parentElement).toBe(toggle.parentElement); 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(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true'); 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'); const { default: DashboardPage } = await import('./page');
render(<DashboardPage />); 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).toHaveBeenCalledTimes(1);
expect(mockStore.setEditMode).toHaveBeenCalledWith(true); 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 { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; 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 { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { registerWidget } from '@/components/dashboard/widget-registry'; import { registerWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
@@ -45,6 +47,13 @@ interface ActiveModule {
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false); 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 — // quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf —
// der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst // der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst
// "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed. // "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed.
@@ -115,7 +124,8 @@ export default function DashboardPage() {
} }
return ( 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 {/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal
in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt
waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine
@@ -131,33 +141,27 @@ export default function DashboardPage() {
onReorder={reorderDashboards} onReorder={reorderDashboards}
/> />
{isSwitchingDashboard ? ( {/* Befehlsleiste rechts ueber dem Raster (Design „Mosaik“, Fluent
<div className="flex min-h-[40vh] items-center justify-center"> „command bar“). Die Begruessung sitzt seit Runde 3 unten in der
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p> Seitenleiste; hier bleibt nur die Leiste. Mit Hintergrund liegt sie
</div> auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
) : ( <div
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */ className="flex justify-end px-3 pt-1 transition-[padding] duration-200 ease-out motion-reduce:transition-none"
<DashboardGrid style={gridInset > 0 ? { paddingInline: 12 + gridInset } : undefined}
layouts={layouts} >
widgets={widgets} <div
isEditMode={isEditMode} role="toolbar"
onLayoutChange={updateLayouts} aria-label={t('toolbarLabel')}
onRemoveWidget={removeWidget} className="dashboard-chrome flex items-center gap-1 rounded-lg"
/> >
)} {isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
{isEditMode && (
{/* Feste Aktionsleiste unten rechts. <button
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das type="button"
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px). onClick={() => setCatalogOpen(true)}
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */} className="btn btn-subtle"
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2"> >
{isEditMode && ( <svg
<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
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@@ -172,15 +176,34 @@ export default function DashboardPage() {
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
{t('addWidget')} {t('addWidget')}
</button> </button>
)} )}
<EditModeToggle <EditModeToggle
isEditMode={isEditMode} isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)} onToggle={() => setEditMode(!isEditMode)}
/> />
</div>
</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 */} {/* Widget catalog modal */}
<WidgetCatalogModal <WidgetCatalogModal
isOpen={catalogOpen} isOpen={catalogOpen}
@@ -17,9 +17,9 @@ export default function SettingsLayout({
const t = useTranslations('settings'); const t = useTranslations('settings');
return ( return (
<div className="flex h-full"> <div className="flex h-full flex-col md:flex-row">
<SettingsSidebar /> <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 */} {/* Back to Dashboard link */}
<Link <Link
href="/" href="/"
+454 -42
View File
@@ -57,45 +57,90 @@
--color-status-idle-fg: var(--status-idle-fg); --color-status-idle-fg: var(--status-idle-fg);
--color-status-orphan-fg: var(--status-orphan-fg); --color-status-orphan-fg: var(--status-orphan-fg);
--color-well: var(--well); --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-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius); --radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px); --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 { :root {
--radius: 0.5rem; /* Design „Mosaik“ (Fluent 2 / GitHub als Vorbild): 6 px fuer Bedien-
--font-sans: "Inter", system-ui, -apple-system, sans-serif; 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 */ /* Layout dimensions */
--sidebar-width: 240px; --sidebar-width: 240px;
--sidebar-width-collapsed: 64px; --sidebar-width-collapsed: 64px;
--header-height: 60px; --header-height: 48px;
/* Light mode colors (OKLCH) */ /* Light mode colors (OKLCH) — Arbeitsflaeche leicht kuehlgrau, Karten weiss */
--background: oklch(0.99 0 0); --background: oklch(0.972 0.003 260);
--foreground: oklch(0.15 0 0); --foreground: oklch(0.2 0.006 260);
--card: oklch(1 0 0); --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: oklch(0.91 0.19 102);
--primary-foreground: oklch(0.20 0.02 90); --primary-foreground: oklch(0.20 0.02 90);
--secondary: oklch(0.96 0 0); --secondary: oklch(0.955 0.003 260);
--secondary-foreground: oklch(0.15 0 0); --secondary-foreground: oklch(0.2 0.006 260);
--muted: oklch(0.96 0 0); --muted: oklch(0.955 0.003 260);
--muted-foreground: oklch(0.55 0 0); --muted-foreground: oklch(0.49 0.01 260);
--accent: oklch(0.96 0 0); --accent: oklch(0.955 0.003 260);
--accent-foreground: oklch(0.15 0 0); --accent-foreground: oklch(0.2 0.006 260);
--destructive: oklch(0.55 0.2 27); --destructive: oklch(0.55 0.2 27);
--destructive-foreground: oklch(0.98 0 0); --destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.90 0 0); --border: oklch(0.915 0.004 260);
--input: oklch(0.90 0 0); --input: oklch(0.87 0 0);
--ring: oklch(0.91 0.19 102); /* 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 tokens */
--sidebar: oklch(0.97 0 0); --sidebar: oklch(0.975 0.003 260);
--sidebar-foreground: oklch(0.15 0 0); --sidebar-foreground: oklch(0.2 0.006 260);
--sidebar-accent: oklch(0.93 0 0); --sidebar-accent: oklch(0.925 0.005 260);
--sidebar-accent-foreground: oklch(0.25 0 0); --sidebar-accent-foreground: oklch(0.16 0.006 260);
--sidebar-border: oklch(0.90 0 0); --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 /* Statusfarben (quick-260924-h7x). Die Flaechentoene stammen aus dem
Design-Plan; als Schrift auf Weiss erreichen sie aber nur 2,6:1 bis 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-idle-fg: oklch(0.50 0.02 260);
--status-orphan-fg: oklch(0.50 0.01 260); --status-orphan-fg: oklch(0.50 0.01 260);
/* Eingelassene Messfelder. */ /* Eingelassene Messfelder. */
--well: oklch(0.975 0.003 260); --well: oklch(0.972 0.003 260);
} }
.dark { .dark {
--background: oklch(0.23 0.01 260); --background: oklch(0.2 0.01 260);
--foreground: oklch(0.95 0 0); --foreground: oklch(0.95 0.003 260);
--card: oklch(0.27 0.01 260); --card: oklch(0.245 0.01 260);
--card-foreground: oklch(0.95 0 0); --card-foreground: oklch(0.95 0.003 260);
--primary: oklch(0.91 0.19 102); --primary: oklch(0.91 0.19 102);
--primary-foreground: oklch(0.20 0.02 90); --primary-foreground: oklch(0.20 0.02 90);
--secondary: oklch(0.30 0.01 260); --secondary: oklch(0.28 0.01 260);
--secondary-foreground: oklch(0.95 0 0); --secondary-foreground: oklch(0.95 0.003 260);
--muted: oklch(0.30 0.01 260); --muted: oklch(0.28 0.01 260);
--muted-foreground: oklch(0.65 0 0); --muted-foreground: oklch(0.72 0.01 260);
--accent: oklch(0.30 0.01 260); --accent: oklch(0.28 0.01 260);
--accent-foreground: oklch(0.95 0 0); --accent-foreground: oklch(0.95 0.003 260);
--destructive: oklch(0.55 0.2 27); --destructive: oklch(0.6 0.2 27);
--destructive-foreground: oklch(0.98 0 0); --destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.30 0.01 260); /* Dunkel: feine helle Haarlinie statt Schatten. */
--input: oklch(0.30 0.01 260); --border: oklch(1 0 0 / 0.09);
--ring: oklch(0.91 0.19 102); --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 tokens (dark) */
--sidebar: oklch(0.20 0.01 260); --sidebar: oklch(0.21 0.01 260);
--sidebar-foreground: oklch(0.95 0 0); --sidebar-foreground: oklch(0.93 0.003 260);
--sidebar-accent: oklch(0.91 0.19 102 / 0.15); --sidebar-accent: oklch(1 0 0 / 0.08);
--sidebar-accent-foreground: oklch(0.91 0.19 102); --sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.30 0.01 260); --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, /* 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. */ 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-down-fg: oklch(0.74 0.16 27);
--status-idle-fg: oklch(0.72 0.02 260); --status-idle-fg: oklch(0.72 0.02 260);
--status-orphan-fg: oklch(0.70 0.01 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 { body {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.875rem;
line-height: 1.25rem;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@@ -181,3 +240,356 @@ body {
.wmde-markdown ul ul { list-style: circle; } .wmde-markdown ul ul { list-style: circle; }
.wmde-markdown ol { list-style: decimal; } .wmde-markdown ol { list-style: decimal; }
.wmde-markdown ul.contains-task-list, .wmde-markdown li.task-list-item { list-style: none; } .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 // das <nav> darunter trAegt die Navigationssemantik schon; ein zweites
// Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer // Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer
// dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>. // 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"> <aside className="flex w-full shrink-0 flex-col border-b border-border py-2 md:w-[220px] md:border-b-0">
<div className="p-4"> <div className="px-5 pb-1 pt-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold text-muted-foreground">
{t('admin.label')} {t('admin.label')}
</h2> </h2>
</div> </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 {items
.filter((item) => item.show) .filter((item) => item.show)
.map((item) => ( .map((item) => (
<Link <Link
key={item.href} key={item.href}
href={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) pathname.startsWith(item.href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-muted' : 'text-sidebar-foreground hover:bg-sidebar-hover'
}`} }`}
aria-current={pathname.startsWith(item.href) ? 'page' : undefined} aria-current={pathname.startsWith(item.href) ? 'page' : undefined}
> >
@@ -20,9 +20,10 @@ export interface TesseraLogoProps {
* Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen * Schaltet die halbtransparente helle Kontur der Grundplatte im dunklen
* Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom * Erscheinungsbild ein. Auf Flaechen, die unabhaengig vom
* Erscheinungsbild immer hell sind (etwa das gelbe Anmelde-Panel), muss * 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. */ /** Zusaetzliche Klassen fuer das aeusserste Element der gewaehlten Variante. */
className?: string; className?: string;
/** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`). */ /** Zusaetzliche Klassen fuer den Schriftzug (nur `horizontal`). */
@@ -38,7 +39,7 @@ function LogoMark({
ariaLabel, ariaLabel,
}: { }: {
size: number; size: number;
plateOutline: boolean; plateOutline: boolean | 'always';
className?: string; className?: string;
ariaLabel: string; ariaLabel: string;
}) { }) {
@@ -61,9 +62,11 @@ function LogoMark({
fill={BRAND_PLATE} fill={BRAND_PLATE}
strokeWidth={2} strokeWidth={2}
className={ className={
plateOutline plateOutline === 'always'
? 'stroke-transparent dark:stroke-white/25' ? 'stroke-white/20'
: 'stroke-transparent' : plateOutline
? 'stroke-transparent dark:stroke-white/25'
: 'stroke-transparent'
} }
/> />
{/* {/*
@@ -34,7 +34,7 @@ export function BugReportButton() {
<button <button
type="button" type="button"
onClick={handleClick} 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')} aria-label={t('button')}
title={t('button')} title={t('button')}
disabled={capturing} disabled={capturing}
@@ -103,7 +103,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
aria-labelledby="bug-report-title" aria-labelledby="bug-report-title"
data-bug-report-ignore="true" 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"> <h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2">
{t('title')} {t('title')}
</h2> </h2>
@@ -115,7 +115,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
<button <button
type="button" type="button"
onClick={onClose} 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')} {t('close')}
</button> </button>
@@ -183,7 +183,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={busy} 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')} {t('cancel')}
</button> </button>
@@ -191,7 +191,7 @@ export function BugReportDialog({ screenshot, isAdmin, onClose }: BugReportDialo
type="button" type="button"
onClick={handleSend} onClick={handleSend}
disabled={busy} 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')} {busy ? t('sending') : t('send')}
</button> </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 { 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 { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer'; import { stubResizeObserver } from '@/test/fake-resize-observer';
import { centeringOffset, RESIZE_AXIS_FALLBACK } from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve // Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -13,6 +14,8 @@ vi.mock('next-intl', () => ({
const map: Record<string, Record<string, string>> = { const map: Record<string, Record<string, string>> = {
widgets: { widgets: {
emptyHeading: 'No active 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.', emptyBody: 'Click edit to add widgets to your dashboard.',
addWidget: 'Add widget', addWidget: 'Add widget',
catalogTitle: '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 () => { it('renders widget instance when widgets are provided', async () => {
@@ -165,7 +194,7 @@ describe('DashboardGrid', () => {
expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument(); 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; captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
render( render(
@@ -181,7 +210,7 @@ describe('DashboardGrid', () => {
expect(captured.props).not.toBeNull(); expect(captured.props).not.toBeNull();
expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }); expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 });
expect(captured.props?.rowHeight).toBe(20); 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?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 });
expect(captured.props?.containerPadding).toBeUndefined(); expect(captured.props?.containerPadding).toBeUndefined();
}); });
@@ -409,7 +438,7 @@ describe('DashboardGrid', () => {
); );
// Leerzustand: der gemessene Knoten ist gar nicht eingehaengt. // 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(); expect(captured.props).toBeNull();
// Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef // Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef
@@ -453,3 +482,30 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1600); 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout'; import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } 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-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl'; 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 { 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, // 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 // 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). // `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true }; 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 { interface LayoutItemShape {
i: string; i: string;
x: number; x: number;
@@ -60,8 +129,17 @@ interface DashboardGridProps {
isEditMode: boolean; isEditMode: boolean;
onLayoutChange: (allLayouts: ResponsiveLayouts) => void; onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
onRemoveWidget: (id: string) => 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. * quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben.
* *
@@ -123,6 +201,9 @@ export function DashboardGrid({
isEditMode, isEditMode,
onLayoutChange, onLayoutChange,
onRemoveWidget, onRemoveWidget,
onOpenCatalog,
onQuickAdd,
onInsetChange,
}: DashboardGridProps) { }: DashboardGridProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [width, setWidth] = useState(1200); const [width, setWidth] = useState(1200);
@@ -203,33 +284,78 @@ export function DashboardGrid({
// stabil bleibt. // stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); 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) { if (widgets.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4"> <div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
{/* Bildmarke statt des frueheren handgezeichneten Vier-Quadrate-Symbols. <div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
Ohne Rahmen und ohne Kontur: die Marke steht hier frei auf dem <span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
Seitenhintergrund, nicht auf einer eigenen Flaeche. */} <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">
<TesseraLogo variant="mark" size={64} plateOutline={false} /> <rect x="3" y="3" width="7" height="9" rx="1.5" />
<h2 className="text-lg font-semibold text-muted-foreground"> <rect x="14" y="3" width="7" height="5" rx="1.5" />
{t('emptyHeading')} <rect x="14" y="12" width="7" height="9" rx="1.5" />
</h2> <rect x="3" y="16" width="7" height="5" rx="1.5" />
<p className="text-sm text-muted-foreground"> </svg>
{t('emptyBody')} </span>
</p> <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> </div>
); );
} }
return ( 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 <Responsive
width={width} width={width}
breakpoints={BREAKPOINTS} breakpoints={BREAKPOINTS}
cols={COLS} cols={COLS}
layouts={effectiveLayouts as ResponsiveLayouts} layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20} 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 // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{ dragConfig={{
@@ -244,7 +370,7 @@ export function DashboardGrid({
compactor={FREE_PLACEMENT_COMPACTOR} compactor={FREE_PLACEMENT_COMPACTOR}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)} onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
> >
{widgets.map((widget) => { {widgets.map((widget, index) => {
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType]; const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
return ( return (
<div <div
@@ -264,11 +390,13 @@ export function DashboardGrid({
widget={widget} widget={widget}
isEditMode={isEditMode} isEditMode={isEditMode}
onRemove={onRemoveWidget} onRemove={onRemoveWidget}
enterIndex={index}
/> />
</div> </div>
); );
})} })}
</Responsive> </Responsive>
</div>
</div> </div>
); );
} }
@@ -312,18 +312,20 @@ export function DashboardTabs({
const bar = ( const bar = (
<nav <nav
aria-label={t('tabs.navLabel')} 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} title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
> >
{/* Eingelassene Spur (32 px): der aktive Reiter liegt erhaben darauf. */} {/* Reiter im Fluent-Stil auf der dunklen App-Leiste (Design „Mosaik“):
<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)]"> 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 <div
ref={scrollRef} ref={scrollRef}
role="tablist" role="tablist"
aria-describedby={dashboards.length > 1 ? hintId : undefined} aria-describedby={dashboards.length > 1 ? hintId : undefined}
onScroll={measureOverflow} onScroll={measureOverflow}
style={maskStyle} 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) => { {orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId; const isActive = tab.id === activeDashboardId;
@@ -339,16 +341,22 @@ export function DashboardTabs({
}} }}
data-tab-index={index} data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`} 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 isActive
? 'bg-card text-foreground shadow-sm' ? 'text-appbar-foreground'
: 'text-muted-foreground hover:bg-card/60 hover:text-foreground' : 'text-appbar-muted hover:text-appbar-foreground'
}`} }`}
onPointerDown={(e) => handlePointerDown(e, tab.id)} onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel} onPointerCancel={handlePointerCancel}
> >
{isActive && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-3 bottom-0 h-[2px] rounded-full bg-primary"
/>
)}
{isRenaming ? ( {isRenaming ? (
<input <input
ref={renameInputRef} ref={renameInputRef}
@@ -366,7 +374,7 @@ export function DashboardTabs({
}} }}
maxLength={40} maxLength={40}
aria-label={t('tabs.renameInputLabel')} 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 <button
@@ -380,8 +388,9 @@ export function DashboardTabs({
tabIndex={isFocusable ? 0 : -1} tabIndex={isFocusable ? 0 : -1}
onClick={() => handleTabClick(tab.id)} onClick={() => handleTabClick(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)} 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 ${ data-text={tab.name}
isActive ? 'font-medium' : '' 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} {tab.name}
@@ -394,7 +403,7 @@ export function DashboardTabs({
onClick={() => startRename(tab)} onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')} aria-label={t('tabs.renameButtonLabel')}
title={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 <svg
aria-hidden="true" aria-hidden="true"
@@ -419,7 +428,7 @@ export function DashboardTabs({
onClick={() => setPendingDeleteId(tab.id)} onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')} aria-label={t('tabs.deleteButtonLabel')}
title={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 <svg
aria-hidden="true" aria-hidden="true"
@@ -453,7 +462,7 @@ export function DashboardTabs({
onClick={onCreate} onClick={onCreate}
aria-label={t('tabs.add')} aria-label={t('tabs.add')}
title={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 <svg
aria-hidden="true" aria-hidden="true"
@@ -498,7 +507,7 @@ export function DashboardTabs({
role="alertdialog" role="alertdialog"
aria-modal="true" aria-modal="true"
aria-label={t('tabs.deleteDialogTitle')} 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"> <h3 className="mb-2 text-lg font-semibold text-foreground">
{t('tabs.deleteDialogTitle')} {t('tabs.deleteDialogTitle')}
@@ -510,7 +519,7 @@ export function DashboardTabs({
<button <button
type="button" type="button"
onClick={() => setPendingDeleteId(null)} 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')} {tCommon('cancel')}
</button> </button>
@@ -21,13 +21,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className={`inline-flex items-center justify-center rounded-md p-2 shadow-lg transition-colors ${ className={`btn ${isEditMode ? 'btn-primary' : 'btn-subtle'}`}
isEditMode
? 'bg-primary text-primary-foreground hover:opacity-90'
: 'border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
aria-pressed={isEditMode} aria-pressed={isEditMode}
aria-label={isEditMode ? t('saveChanges') : t('editMode')}
title={isEditMode ? t('saveChanges') : t('editMode')} title={isEditMode ? t('saveChanges') : t('editMode')}
> >
{isEditMode ? ( {isEditMode ? (
@@ -35,8 +30,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="20" width="16"
height="20" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -46,13 +41,14 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
> >
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
) : ( ) : null}
{isEditMode ? null : (
// Pencil icon // Pencil icon
<svg <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="20" width="16"
height="20" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" 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" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
)} )}
{isEditMode ? t('done') : t('editShort')}
</button> </button>
); );
} }
@@ -74,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(WIDGET_CONSTRAINTS).toEqual({ expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, 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 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 1, minH: 3, 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 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe // quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. // 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 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
// Phase 8 new widgets (D-01) // Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat // 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. // 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 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 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 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)]`; 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. // Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt.
@@ -283,6 +283,27 @@ export function formatEventDate(event: CalendarEvent): string {
}).format(start); }).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“. */ /** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
export function formatEventTime(iso: string): string { export function formatEventTime(iso: string): string {
return new Intl.DateTimeFormat('de-DE', { return new Intl.DateTimeFormat('de-DE', {
@@ -169,7 +169,7 @@ describe('CalendarWidget', () => {
const badge = within(day20).getByTestId('calendar-day-count'); const badge = within(day20).getByTestId('calendar-day-count');
expect(badge).toHaveTextContent('2'); expect(badge).toHaveTextContent('2');
expect(badge).toHaveStyle({ backgroundColor: '#c44040' }); expect(badge).toHaveStyle({ backgroundColor: '#c44040' });
expect(badge).toHaveClass('text-white'); expect(badge).toHaveAttribute('data-count', '2');
expect(badge).not.toHaveClass('bg-primary'); expect(badge).not.toHaveClass('bg-primary');
fireEvent.mouseEnter(day20); 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([ mockFetchEvents.mockResolvedValue([
ev('Ohne Farbe', new Date(2026, 6, 21, 9, 0), new Date(2026, 6, 21, 10, 0), { ev('Ohne Farbe', new Date(2026, 6, 21, 9, 0), new Date(2026, 6, 21, 10, 0), {
color: undefined, color: undefined,
@@ -199,9 +199,9 @@ describe('CalendarWidget', () => {
const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement; const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement;
const badge = within(day21).getByTestId('calendar-day-count'); const badge = within(day21).getByTestId('calendar-day-count');
expect(badge).toHaveClass('bg-primary'); // Design „Mosaik“: Terminpunkt ohne Kalenderfarbe im abgedunkelten Akzent.
expect(badge).toHaveClass('text-primary-foreground'); expect(badge).toHaveClass('bg-primary-strong');
expect(badge).not.toHaveClass('text-white'); expect(badge).toHaveAttribute('data-count', '1');
expect((badge as HTMLElement).style.backgroundColor).toBe(''); expect((badge as HTMLElement).style.backgroundColor).toBe('');
fireEvent.mouseEnter(day21); fireEvent.mouseEnter(day21);
@@ -240,9 +240,9 @@ describe('CalendarWidget', () => {
expect(badges[1]).toHaveTextContent('2'); expect(badges[1]).toHaveTextContent('2');
expect(badges[1]).toHaveStyle({ backgroundColor: '#4060c4' }); expect(badges[1]).toHaveStyle({ backgroundColor: '#4060c4' });
for (const badge of badges) { for (const badge of badges) {
expect(badge).toHaveClass('text-white'); expect(badge).toHaveClass('rounded-full');
expect(badge).not.toHaveClass('bg-primary'); 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).queryByTestId('calendar-day-count-rest')).toBeNull();
expect(within(day20).getByTestId('calendar-day-badges').childElementCount).toBe(2); 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'); const restBadge = within(day21).getByTestId('calendar-day-count-rest');
expect(restBadge).toHaveTextContent('4'); expect(restBadge).toHaveTextContent('4');
expect(restBadge).toHaveClass('bg-muted-foreground'); 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 as HTMLElement).style.backgroundColor).toBe('');
expect(restBadge).not.toHaveClass('text-white'); expect(restBadge).not.toHaveClass('text-white');
}); });
@@ -409,12 +409,15 @@ describe('CalendarWidget', () => {
expect(items).toHaveLength(2); expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('E1'); expect(items[0]).toHaveTextContent('E1');
expect(items[1]).toHaveTextContent('E2'); 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) { for (const item of items) {
expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument(); 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 () => { it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => {
@@ -10,7 +10,9 @@ import {
buildCalendarDays, buildCalendarDays,
buildDayBadges, buildDayBadges,
computeFetchWindow, computeFetchWindow,
eventDayOffset,
formatEventDate, formatEventDate,
formatEventDay,
formatEventTime, formatEventTime,
formatMonthLabel, formatMonthLabel,
groupEventsByDate, groupEventsByDate,
@@ -243,21 +245,23 @@ export function CalendarWidget({ config }: WidgetProps) {
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : []; const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
return ( 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 && ( {showMonth && (
<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1"> <div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">
{/* Nav-Zeile */} {/* Nav-Zeile: Pfeilknoepfe links/rechts, Monatsname mittig */}
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1"> <div className="grid grid-cols-[auto_1fr_auto] items-center gap-1">
<button <button
type="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} 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>
<button <button
type="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')} title={t('calendar.monthToday')}
onClick={showToday} onClick={showToday}
> >
@@ -265,31 +269,35 @@ export function CalendarWidget({ config }: WidgetProps) {
</button> </button>
<button <button
type="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} 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> </button>
</div> </div>
{/* Wochentagskopf */} {/* 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) => ( {WEEKDAY_LABELS.map((label) => (
<div key={label}>{label}</div> <div key={label}>{label}</div>
))} ))}
</div> </div>
{/* Raster */} {/* Raster */}
<div className="grid grid-cols-7 gap-px"> <div className="grid grid-cols-7 gap-y-0.5">
{days.map((day) => { {days.map((day) => {
const hasEvents = day.events.length > 0; const hasEvents = day.events.length > 0;
const badges = buildDayBadges(day.events); 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 = [ 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', '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/60', day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/70',
day.isToday ? 'ring-1 ring-primary font-semibold text-primary' : '', day.isToday ? 'bg-primary font-semibold text-primary-foreground' : '',
hasEvents ? 'cursor-default hover:bg-muted' : '', hasEvents && !day.isToday ? 'cursor-default font-medium hover:bg-muted' : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@@ -298,23 +306,20 @@ export function CalendarWidget({ config }: WidgetProps) {
<> <>
<span>{day.date.getDate()}</span> <span>{day.date.getDate()}</span>
{hasEvents && ( {hasEvents && (
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht
// mehr (mehrere Kreise stehen sonst uebereinander).
<span <span
data-testid="calendar-day-badges" 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) => { {badges.map((badge) => {
const badgeClass = [ const badgeClass = [
'flex items-center justify-center rounded-full px-0.5 font-semibold leading-none', 'block h-1 w-1 rounded-full',
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)]',
badge.rest badge.rest
? 'bg-muted-foreground text-background' ? 'bg-muted-foreground'
: badge.color : badge.color
? 'text-white' ? ''
: 'bg-primary text-primary-foreground', : day.isToday
? 'bg-primary-foreground'
: 'bg-primary-strong',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@@ -322,10 +327,11 @@ export function CalendarWidget({ config }: WidgetProps) {
<span <span
key={badge.key} key={badge.key}
data-testid={badge.rest ? 'calendar-day-count-rest' : 'calendar-day-count'} data-testid={badge.rest ? 'calendar-day-count-rest' : 'calendar-day-count'}
data-count={badge.count}
className={badgeClass} className={badgeClass}
style={badge.color && !badge.rest ? { backgroundColor: badge.color } : undefined} style={badge.color && !badge.rest ? { backgroundColor: badge.color } : undefined}
> >
{badge.count} <span className="sr-only">{badge.count}</span>
</span> </span>
); );
})} })}
@@ -360,7 +366,7 @@ export function CalendarWidget({ config }: WidgetProps) {
data-date={day.key} data-date={day.key}
data-today={day.isToday || undefined} data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined} data-outside={!day.inCurrentMonth || undefined}
className={`${cellClass} w-full text-left`} className={cellClass}
aria-label={ariaLabel} aria-label={ariaLabel}
onMouseEnter={(e) => onMouseEnter={(e) =>
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() }) setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
@@ -395,7 +401,7 @@ export function CalendarWidget({ config }: WidgetProps) {
{maxEvents > 0 && ( {maxEvents > 0 && (
<section className="flex min-h-0 flex-1 flex-col gap-1"> <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')} {t('calendar.upcomingTitle')}
</h3> </h3>
{upcoming.length === 0 ? ( {upcoming.length === 0 ? (
@@ -403,30 +409,48 @@ export function CalendarWidget({ config }: WidgetProps) {
{t('calendar.emptyNoEvents')} {t('calendar.emptyNoEvents')}
</p> </p>
) : ( ) : (
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto"> // Einzeilige Terminliste (Design „Mosaik“, Runde 3): Farbstrich,
{upcoming.map((event) => ( // Tag, Titel, Uhrzeit rechts — rund 26 px je Termin statt 40, so
<li key={event.id} className="flex items-start gap-2 rounded bg-muted/50 px-2 py-1"> // passen auch in eine kleine Kachel mehrere Termine. „Heute“ und
<span // „Morgen“ stehen in der Akzentfarbe.
data-testid="event-color-dot" <ul data-testid="calendar-upcoming" className="min-h-0 flex-1 overflow-y-auto">
className="mt-1 h-2 w-2 shrink-0 rounded-full" {upcoming.map((event) => {
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }} const offset = eventDayOffset(event);
aria-hidden="true" const dayLabel =
/> offset === 0
<div className="min-w-0 flex-1"> ? t('calendar.today')
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground"> : offset === 1
{formatEventDate(event)} ? t('calendar.tomorrow')
</p> : formatEventDay(event);
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-semibold text-foreground"> 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} {event.title}
</p> </span>
{event.location && ( <span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
<p className="truncate text-[clamp(9px,2.1cqw,12px)] text-muted-foreground"> {event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
{event.location} </span>
</p> </li>
)} );
</div> })}
</li>
))}
</ul> </ul>
)} )}
</section> </section>
@@ -438,7 +462,7 @@ export function CalendarWidget({ config }: WidgetProps) {
<div <div
data-testid="calendar-day-tooltip" data-testid="calendar-day-tooltip"
role="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={{ style={{
width: TOOLTIP_WIDTH_PX, width: TOOLTIP_WIDTH_PX,
top: hover.rect.bottom + TOOLTIP_EDGE_PX, top: hover.rect.bottom + TOOLTIP_EDGE_PX,
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { WidgetIcon } from './widget-icon';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { updateWidgetConfig } from '@/lib/dashboard-api'; import { updateWidgetConfig } from '@/lib/dashboard-api';
@@ -321,7 +322,8 @@ export function FavoritesWidget({
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */} {/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
{showHeader && ( {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 ? ( {isEditMode ? (
<input <input
type="text" type="text"
@@ -341,7 +343,7 @@ export function FavoritesWidget({
</div> </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) */} {/* View mode toggle (edit mode only) */}
{isEditMode && ( {isEditMode && (
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten") // role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
@@ -359,8 +361,8 @@ export function FavoritesWidget({
onClick={() => handleViewMode('list')} onClick={() => handleViewMode('list')}
className={`px-2 py-1 text-xs rounded ${ className={`px-2 py-1 text-xs rounded ${
viewMode === 'list' viewMode === 'list'
? 'bg-primary text-primary-foreground' ? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t('favorites.listView')} {t('favorites.listView')}
@@ -370,8 +372,8 @@ export function FavoritesWidget({
onClick={() => handleViewMode('grid')} onClick={() => handleViewMode('grid')}
className={`px-2 py-1 text-xs rounded ${ className={`px-2 py-1 text-xs rounded ${
viewMode === 'grid' viewMode === 'grid'
? 'bg-primary text-primary-foreground' ? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t('favorites.gridView')} {t('favorites.gridView')}
@@ -396,10 +398,11 @@ export function FavoritesWidget({
viewMode === 'grid' ? ( viewMode === 'grid' ? (
<div <div
data-testid="favorites-grid" 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) => ( {sortedFavorites.map((fav, index) => (
<FavoriteTile <FavoriteTile
launcher
key={fav.id} key={fav.id}
fav={fav} fav={fav}
isEditMode={isEditMode} isEditMode={isEditMode}
@@ -429,7 +432,7 @@ export function FavoritesWidget({
) : ( ) : (
<div <div
data-testid="favorites-list" data-testid="favorites-list"
className="flex flex-col gap-1" className="flex flex-col gap-0.5"
> >
{sortedFavorites.map((fav, index) => ( {sortedFavorites.map((fav, index) => (
<FavoriteTile <FavoriteTile
@@ -558,10 +561,16 @@ function getDirectFaviconSrc(url: string): string | null {
function FavoriteIcon({ function FavoriteIcon({
fav, fav,
getFallbackLetter, getFallbackLetter,
large = false,
}: { }: {
fav: FavoriteLink; fav: FavoriteLink;
getFallbackLetter: (title: string) => string; 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 hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime);
const proxySrc = hasServerIcon const proxySrc = hasServerIcon
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}` ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
@@ -572,10 +581,10 @@ function FavoriteIcon({
); );
return ( 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 <span
data-testid={`letter-fallback-${fav.id}`} 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)} {getFallbackLetter(fav.title)}
</span> </span>
@@ -585,10 +594,10 @@ function FavoriteIcon({
src={proxySrc} src={proxySrc}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={20} width={px}
height={20} height={px}
loading="lazy" loading="lazy"
className="absolute inset-0 w-5 h-5 rounded" className={`relative rounded-sm ${img}`}
onError={() => setStage('direct')} onError={() => setStage('direct')}
/> />
)} )}
@@ -598,11 +607,11 @@ function FavoriteIcon({
src={directSrc} src={directSrc}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={20} width={px}
height={20} height={px}
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
className="absolute inset-0 w-5 h-5 rounded" className={`relative rounded-sm ${img}`}
onError={() => setStage('none')} onError={() => setStage('none')}
/> />
)} )}
@@ -638,6 +647,8 @@ type FavoriteTileProps = {
canMoveDown: boolean; canMoveDown: boolean;
onMove: (id: string, direction: 'up' | 'down') => void; onMove: (id: string, direction: 'up' | 'down') => void;
t: (key: string) => string; t: (key: string) => string;
/** Kachelansicht wie ein App-Starter (Symbol oben, Name darunter). */
launcher?: boolean;
}; };
function FavoriteTile({ function FavoriteTile({
@@ -663,19 +674,24 @@ function FavoriteTile({
canMoveDown, canMoveDown,
onMove, onMove,
t, t,
launcher = false,
}: FavoriteTileProps) { }: FavoriteTileProps) {
const isEditing = editingId === fav.id; const isEditing = editingId === fav.id;
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{/* Main link row */} {/* 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) */} {/* Anchor — opens in new tab, no server-side redirect (T-08-08) */}
<a <a
href={fav.url} href={fav.url}
target="_blank" target="_blank"
rel="noreferrer" 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 onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
> >
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */} {/* 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 ?? ''}`} key={`${fav.iconUrl ?? ''}|${fav.url}|${fav.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
fav={fav} fav={fav}
getFallbackLetter={getFallbackLetter} 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> </a>
{/* Action buttons (edit mode only, D-04) */} {/* Action buttons (edit mode only, D-04) */}
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { WidgetIcon } from './widget-icon';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
@@ -163,7 +164,8 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
return ( return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}> <div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
{/* Header: title + pencil toggle */} {/* 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 <input
type="text" type="text"
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground" 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 <button
type="button" type="button"
onClick={() => setIsEditing((v) => !v)} 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')} title={isEditing ? t('note.viewMode') : t('note.editMode')}
> >
<svg <svg
@@ -203,7 +205,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
{/* Content: preview or editor */} {/* Content: preview or editor */}
<div <div
className="flex-1 overflow-auto" className="widget-note-body flex-1 overflow-auto"
data-testid="note-preview" data-testid="note-preview"
ref={previewRef} ref={previewRef}
> >
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { WidgetIcon } from './widget-icon';
import Link from 'next/link'; import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; 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; let header: ReactNode = null;
if (isEditMode) { if (isEditMode) {
header = ( 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 <input
type="text" type="text"
className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground" 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 !== '') { } else if (trimmedTitle !== '') {
header = ( 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> <h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2>
</div> </div>
); );
@@ -113,7 +113,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
{/* Search button (right) */} {/* Search button (right) */}
<button <button
type="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} onClick={handleSearch}
aria-label={t('search.searchButton')} 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'; 'use client';
import type { CSSProperties } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry'; 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 { interface WidgetWrapperProps {
widget: { widget: {
@@ -11,6 +22,8 @@ interface WidgetWrapperProps {
}; };
isEditMode: boolean; isEditMode: boolean;
onRemove: (id: string) => void; 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. * .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup. * 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 t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType]; const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component; 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 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' ? 'widget-card widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
: 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm'; : 'widget-card relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
return ( return (
<article <article
className={cardClassName} className={cardClassName}
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 80}ms` } as CSSProperties}
data-widget-id={widget.id} data-widget-id={widget.id}
aria-label={definition ? t(definition.nameKey) : widget.widgetType} 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). */} Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
{isEditMode && ( {isEditMode && (
<div <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')} title={t('dragHint')}
data-testid="widget-drag-head" data-testid="widget-drag-head"
> >
@@ -103,7 +119,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
im Bearbeitungsmodus zusaetzlich den Griff traegt. im Bearbeitungsmodus zusaetzlich den Griff traegt.
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */} 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 ? (
<WidgetComponent <WidgetComponent
instanceId={widget.id} instanceId={widget.id}
@@ -122,6 +145,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
</div> </div>
)} )}
</div> </div>
</div>
</article> </article>
); );
} }
@@ -84,12 +84,12 @@ describe('XframeWidget (quick-260921-qd3)', () => {
expect(link.className).toContain('absolute'); 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' }); const { container } = renderWidget({ url: URL_A, title: 'Board' });
expect(container.querySelector('h2')).toHaveTextContent('Board'); expect(container.querySelector('h2')).toHaveTextContent('Board');
expect(frame().getAttribute('title')).toBe('Board'); expect(frame().getAttribute('title')).toBe('Board');
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' }); 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'); expect(link.className).not.toContain('absolute');
}); });
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { WidgetIcon } from './widget-icon';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import type { WidgetProps } from '@/components/dashboard/widget-registry'; 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 // Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den // Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen. // 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> <h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
<NewTabLink <NewTabLink
url={url} url={url}
label={openLabel} 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> </div>
)} )}
+25 -4
View File
@@ -1,6 +1,7 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { useNavStore } from '@/lib/stores/nav-store';
/** /**
* header.test — Sitzungswaechter im Header (quick-260917-gyd). * 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()); await waitFor(() => expect(fetchSessionState).toHaveBeenCalled());
}); });
it('auf anderen Seiten: „Startseite“ bleibt wie bisher', async () => { it('auf anderen Seiten: Name der aktuellen Seite statt „Startseite“ (Design „Mosaik“)', async () => {
mockPathname = '/modules/proxmox'; mockPathname = '/marketplace';
vi.stubGlobal('location', { href: '', pathname: '/modules/proxmox', search: '' }); vi.stubGlobal('location', { href: '', pathname: '/marketplace', search: '' });
fetchSessionState.mockResolvedValue({ status: 'unavailable' }); fetchSessionState.mockResolvedValue({ status: 'unavailable' });
await renderHeader(); 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(); expect(document.getElementById('header-center-slot')).not.toBeNull();
await waitFor(() => expect(fetchSessionState).toHaveBeenCalled()); 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 { BugReportButton } from '@/components/bug-report/bug-report-button';
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
export function Header() { export function Header() {
const t = useTranslations(); const t = useTranslations();
@@ -95,14 +96,25 @@ export function Header() {
const roleLabel = user ? tHeader(`role.${user.role}`) : ''; 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 ( 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"> <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)]">
{/* Left: Logo + Mobile hamburger */} {/* Links: Menue (mobil) + Bildmarke. Dunkle App-Leiste wie bei GitHub
<div className="flex shrink-0 items-center gap-3"> bzw. Microsoft 365 — in beiden Erscheinungsbildern dunkel. */}
<div className="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
onClick={() => setMobileOpen(true)} 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')} aria-label={t('common.menu')}
> >
<svg <svg
@@ -122,17 +134,21 @@ export function Header() {
<line x1="3" y1="18" x2="21" y2="18" /> <line x1="3" y1="18" x2="21" y2="18" />
</svg> </svg>
</button> </button>
<TesseraLogo <Link
variant="horizontal" href="/"
size={28} className="flex items-center rounded-md px-1 py-1 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen >
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer <TesseraLogo
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher variant="horizontal"
// die Vordergrundfarbe, im dunklen steht die Markenfarbe. size={26}
// Unter 640 px nur die Bildmarke: der Platz gehoert dort den plateOutline="always"
// Dashboard-Reitern in der Kopfzeilenmitte (260924-h7x). className="gap-2.5"
wordmarkClassName="hidden sm:inline dark:text-primary" // 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> </div>
{/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x). {/* 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. */} 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 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" /> <div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
{pathname !== '/' && ( {pageTitleText !== null && (
<span className="text-sm text-muted-foreground truncate"> <span className="truncate text-sm font-semibold text-appbar-foreground">
{tHeader('breadcrumb.home')} {pageTitleText}
</span> </span>
)} )}
</div> </div>
{/* Right: Actions */} {/* Right: Actions */}
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-1">
<BugReportButton /> <BugReportButton />
<ThemeToggle /> <ThemeToggle />
@@ -160,7 +176,7 @@ export function Header() {
<button <button
type="button" type="button"
onClick={() => setDropdownOpen(!dropdownOpen)} 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')} aria-label={tHeader('userMenu')}
> >
{user?.hasAvatar && !avatarError ? ( {user?.hasAvatar && !avatarError ? (
@@ -178,7 +194,7 @@ export function Header() {
{/* Dropdown menu */} {/* Dropdown menu */}
{dropdownOpen && ( {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 */} {/* User info */}
<div className="px-4 py-2 border-b border-border"> <div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-foreground truncate"> <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'); const t = useTranslations('sidebar');
return ( 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 <input
type="text" type="search"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={t('search')} placeholder={t('search')}
aria-label={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> </div>
); );
@@ -66,6 +66,10 @@ vi.mock('@/components/layout/sidebar-footer', () => ({
// Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock // Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock
// bekaeme es das Modul-Array aus `stubFetch()` (quick-260914-ku1). // 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', () => ({ vi.mock('@/components/layout/app-version-badge', () => ({
AppVersionBadge: () => <div data-testid="app-version-badge" />, AppVersionBadge: () => <div data-testid="app-version-badge" />,
})); }));
@@ -147,12 +151,10 @@ describe('Sidebar', () => {
const Sidebar = await importSidebar(); const Sidebar = await importSidebar();
render(<Sidebar />); render(<Sidebar />);
// Categories are collapsed by default — wait for category header, then expand // Design „Mosaik“: Kategorien sind anfangs aufgeklappt.
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Domain Tools')).toBeInTheDocument(); expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
}); });
await userEvent.click(screen.getByText('Domain Tools'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument(); 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 () => { it('re-fetches modules when sidebarRefreshKey changes', async () => {
const Sidebar = await importSidebar(); const Sidebar = await importSidebar();
const { rerender } = render(<Sidebar />); const { rerender } = render(<Sidebar />);
// Wait for category header to confirm initial fetch completed // Wait for category header to confirm initial fetch completed
await waitFor(() => { 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; 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 { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search'; import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge'; 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'; 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 { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]); const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
const [searchQuery, setSearchQuery] = useState(''); 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 pathname = usePathname();
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey); const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
@@ -34,25 +53,34 @@ export function Sidebar() {
return pathname.startsWith(href); return pathname.startsWith(href);
}; };
const linkClass = (href: string) => // Design „Mosaik“ (Fluent 2): 36 px hohe Eintraege, 8 px Radius, die
`flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${ // Auswahl traegt eine neutrale Flaeche, halbfette Schrift und links eine
isActive(href) // 3 x 16 px grosse Pille in der Akzentfarbe (`SelectionPill`).
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' const itemClass = (active: boolean) =>
: 'text-sidebar-foreground hover:bg-muted' `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 () => { const fetchActiveModules = useCallback(async () => {
try { try {
const res = await fetch(`${API_URL}/modules/active`, { const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include', credentials: 'include',
}); });
if (res.ok) { if (res.ok) {
setActiveModules(await res.json()); const modules: SidebarModule[] = await res.json();
setActiveModules(modules);
setNavModules(modules);
} }
} catch { } catch {
// silently fail // silently fail
} }
}, []); }, [setNavModules]);
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch- // Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch // Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
@@ -64,7 +92,7 @@ export function Sidebar() {
}, [fetchActiveModules, sidebarRefreshKey]); }, [fetchActiveModules, sidebarRefreshKey]);
const toggleCategory = (cat: string) => { const toggleCategory = (cat: string) => {
setOpenCategories((prev) => { setClosedCategories((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(cat)) next.delete(cat); if (next.has(cat)) next.delete(cat);
else next.add(cat); else next.add(cat);
@@ -85,7 +113,7 @@ export function Sidebar() {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const result: [string, SidebarModule[]][] = []; const result: [string, SidebarModule[]][] = [];
for (const [cat, mods] of categories.entries()) { 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]); result.push([cat, mods]);
} else { } else {
const filtered = mods.filter((m) => m.name.toLowerCase().includes(q)); const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
@@ -93,115 +121,144 @@ export function Sidebar() {
} }
} }
return result; 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 = ( const sidebarContent = (
<div className="flex h-full flex-col bg-sidebar"> <div className="flex h-full flex-col bg-sidebar">
<nav className="flex-1 overflow-y-auto p-3"> <nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-0.5">
<li> <li>
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')}> <Link href="/" className={linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : 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"> {isActive('/') && <SelectionPill />}
<rect x="3" y="3" width="7" height="7" /> {navIcon(
<rect x="14" y="3" width="7" height="7" /> <>
<rect x="14" y="14" width="7" height="7" /> <rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="3" y="14" width="7" height="7" /> <rect x="14" y="3" width="7" height="5" rx="1.5" />
</svg> <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>} {!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')}> <Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : 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"> {isActive('/marketplace') && <SelectionPill />}
<circle cx="9" cy="21" r="1" /> {navIcon(
<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" /> <path d="M3 9h18l-1.5 10.5a2 2 0 0 1-2 1.5h-11a2 2 0 0 1-2-1.5z" />
</svg> <path d="M8 9V7a4 4 0 0 1 8 0v2" />
</>,
)}
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>} {!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
</Link> </Link>
</li> </li>
</ul> </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 && ( {!isCollapsed && (
<> <>
<div className="mt-4"> <div className="mt-4 border-t border-sidebar-border pt-3">
<SidebarSearch value={searchQuery} onChange={setSearchQuery} /> <SidebarSearch value={searchQuery} onChange={setSearchQuery} />
</div> </div>
<div className="mt-2"> <div className="mt-1" aria-label={t('categories.label')} role="group">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground"> {activeModules.length === 0 ? (
{t('categories.label')} <div className="px-2.5 py-2 text-sm text-muted-foreground">
</div> {t('categories.empty')}
<div className="mt-1"> </div>
{activeModules.length === 0 ? ( ) : filteredCategories.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground"> <div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('categories.empty')} {t('noResults')}
</div> </div>
) : filteredCategories.length === 0 ? ( ) : (
<div className="px-2 py-2 text-sm text-muted-foreground"> <ul className="flex flex-col gap-2">
{t('noResults')} {filteredCategories.map(([category, mods]) => {
</div> // Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
) : ( const open = searchQuery !== '' || !closedCategories.has(category);
<ul className="flex flex-col gap-1"> return (
{filteredCategories.map(([category, mods]) => (
<li key={category}> <li key={category}>
<button <button
type="button" type="button"
onClick={() => toggleCategory(category)} onClick={() => toggleCategory(category)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${ aria-expanded={open}
isActive(`/modules/${category}`) 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"
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
> >
<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"> <span className="truncate">{categoryLabel(category)}</span>
<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" /> <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> </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> </button>
{openCategories.has(category) && ( {open && (
<ul className="ml-5 flex flex-col gap-0.5"> <ul className="mt-0.5 flex flex-col gap-0.5">
{mods.map((mod) => ( {mods.map((mod) => {
<li key={mod.id}> const href = `/modules/${category}/${mod.slug}`;
<Link const active = isActive(href) || isActive(`/modules/${mod.slug}`);
href={`/modules/${category}/${mod.slug}`} return (
className={`block rounded-md px-2 py-1 text-xs transition-colors ${ <li key={mod.id}>
isActive(`/modules/${category}/${mod.slug}`) <Link href={href} className={itemClass(active)}>
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' {active && <SelectionPill />}
: 'text-muted-foreground hover:bg-muted hover:text-foreground' <ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
}`} <span className="truncate">{mod.name}</span>
> </Link>
{mod.name} </li>
</Link> );
</li> })}
))}
</ul> </ul>
)} )}
</li> </li>
))} );
</ul> })}
)} </ul>
</div> )}
</div> </div>
</> </>
)} )}
</nav> </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 <button
type="button" type="button"
onClick={toggle} 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')} 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' : ''}`}> <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' : ''}`}>
<polyline points="15 18 9 12 15 6" /> <rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
</svg> </svg>
{!isCollapsed && <span className="truncate">{t('collapse')}</span>} {!isCollapsed && <span className="truncate">{t('collapse')}</span>}
</button> </button>
@@ -210,7 +267,7 @@ export function Sidebar() {
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit {/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */} auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
{!isCollapsed && ( {!isCollapsed && (
<div className="border-t border-sidebar-border px-4 py-2"> <div className="px-5 pb-3 pt-1">
<AppVersionBadge /> <AppVersionBadge />
</div> </div>
)} )}
@@ -220,7 +277,7 @@ export function Sidebar() {
return ( return (
<> <>
<aside <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={{ style={{
width: isCollapsed width: isCollapsed
? 'var(--sidebar-width-collapsed)' ? 'var(--sidebar-width-collapsed)'
@@ -231,28 +288,27 @@ export function Sidebar() {
</aside> </aside>
{isMobileOpen && ( {isMobileOpen && (
<div className="fixed inset-0 z-40 md:hidden"> <div className="fixed inset-0 z-[60] md:hidden">
<div <div
className="fixed inset-0 bg-black/50" className="fixed inset-0 bg-black/50"
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
aria-hidden="true" aria-hidden="true"
/> />
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg"> <aside className="fixed left-0 top-0 z-50 flex h-full w-[var(--sidebar-width)] flex-col bg-sidebar shadow-xl">
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4"> {/* 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 <TesseraLogo
variant="horizontal" variant="horizontal"
size={28} size={26}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen plateOutline="always"
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer className="gap-2.5"
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher wordmarkClassName="text-[15px] font-semibold tracking-normal text-appbar-foreground"
// die Vordergrundfarbe, im dunklen steht die Markenfarbe. />
wordmarkClassName="dark:text-primary"
/>
<button <button
type="button" type="button"
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
aria-label={tCommon('close')} 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"> <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" /> <line x1="18" y1="6" x2="6" y2="18" />
@@ -260,7 +316,7 @@ export function Sidebar() {
</svg> </svg>
</button> </button>
</div> </div>
{sidebarContent} <div className="min-h-0 flex-1">{sidebarContent}</div>
</aside> </aside>
</div> </div>
)} )}
@@ -20,16 +20,16 @@ export function ModuleAccessDenied({ title, body, backToDashboard }: ModuleAcces
return ( return (
<div className="mx-auto max-w-2xl space-y-6 p-6"> <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="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 <svg
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="48" width="28"
height="48" height="28"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.75"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-muted-foreground" 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" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
</div> </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> <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} {backToDashboard}
</Link> </Link>
</div> </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 % * `fill` = Flaeche (Leiste, Punkt, Balken), `pill` = Statuspille (12 %
* Flaeche + Textvariante), `text` = Schrift in Statusfarbe, `shadow` = * 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. * Seit quick-260924-i8v gemeinsam fuer Modulseite und Dashboard-Kachel.
*/ */
@@ -19,32 +20,31 @@ export const HEALTH_STYLE: Record<
fill: 'bg-status-ok', fill: 'bg-status-ok',
pill: 'bg-status-ok/12 text-status-ok-fg', pill: 'bg-status-ok/12 text-status-ok-fg',
text: '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: { warn: {
fill: 'bg-status-warn', fill: 'bg-status-warn',
pill: 'bg-status-warn/12 text-status-warn-fg', pill: 'bg-status-warn/12 text-status-warn-fg',
text: '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: { down: {
fill: 'bg-status-down', fill: 'bg-status-down',
pill: 'bg-status-down/12 text-status-down-fg', pill: 'bg-status-down/12 text-status-down-fg',
text: '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: { idle: {
fill: 'bg-status-idle', fill: 'bg-status-idle',
pill: 'bg-status-idle/12 text-status-idle-fg', pill: 'bg-status-idle/12 text-status-idle-fg',
text: '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: { orphan: {
fill: 'bg-status-orphan', fill: 'bg-status-orphan',
pill: 'bg-status-orphan/12 text-status-orphan-fg', pill: 'bg-status-orphan/12 text-status-orphan-fg',
text: '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“). */ /** 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" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} 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"> <div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
<h2 <h2
@@ -215,7 +215,7 @@ export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProp
<button <button
type="button" type="button"
onClick={close} 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')} {t('confirm')}
</button> </button>
@@ -197,7 +197,7 @@ export function AccountSettingsForm() {
disabled={isAvatarPending} disabled={isAvatarPending}
onChange={handleAvatarUpload} 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')} {isAvatarPending ? '...' : t('account.uploadCta')}
</span> </span>
</label> </label>
@@ -281,7 +281,7 @@ export function AccountSettingsForm() {
type="button" type="button"
onClick={handleSaveAccentColor} onClick={handleSaveAccentColor}
disabled={isAccentPending || !isHexValid} 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')} {isAccentPending ? '...' : t('account.accentColorSave')}
</button> </button>
@@ -365,7 +365,7 @@ export function AccountSettingsForm() {
<button <button
type="submit" type="submit"
disabled={isPwPending} 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 ? ( {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"> <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 <button
type="button" type="button"
onClick={() => setShowAddForm(true)} 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')} {t('calendar.addSource')}
</button> </button>
@@ -394,7 +394,7 @@ export function CalendarSettingsPanel() {
/> />
<div <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" role="alertdialog"
aria-modal="true" aria-modal="true"
aria-label={t('calendar.deleteDialogLabel')} aria-label={t('calendar.deleteDialogLabel')}
@@ -409,7 +409,7 @@ export function CalendarSettingsPanel() {
<button <button
type="button" type="button"
onClick={() => setDeletingId(null)} 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')} {ts('cancel')}
</button> </button>
@@ -355,7 +355,7 @@ export function CalendarSourceForm({
type="submit" type="submit"
data-testid="source-form-submit" data-testid="source-form-submit"
disabled={!isFormValid || isSaving} 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')} {isSaving ? t('calendar.formSaving') : t('calendar.formSave')}
</button> </button>
@@ -364,14 +364,14 @@ export function CalendarSourceForm({
data-testid="source-form-test" data-testid="source-form-test"
disabled={!isFormValid || testStatus === 'loading'} disabled={!isFormValid || testStatus === 'loading'}
onClick={handleTest} 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')} {testStatus === 'loading' ? t('calendar.formTesting') : t('calendar.formTestConnection')}
</button> </button>
<button <button
type="button" type="button"
onClick={onCancel} 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')} {t('calendar.formCancel')}
</button> </button>

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