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:
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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')} →
|
{t('page.settingsTitle')} →
|
||||||
</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 />
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
+1
-1
@@ -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')} →
|
{t('mySources.title')} →
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user