feat(web): Dashboard-Kacheln im Fluent-Stil und waehlbarer Hintergrund
Einheitliche Kachel-Kopfzeile, ruhigere Notiz-/Kalender-/Favoriten- Darstellung, 12 px Raster-Abstand, gestrichelte Kontur und Griff im Bearbeitungsmodus. Neuer Knopf Hintergrund: Keiner, fuenf eingebaute (Nebel, Salbei, Bluete, Duenen, Mosaik) oder eigenes Bild aus den Bilderrahmen-Bildern; Prototyp speichert im localStorage je Benutzer. Mit Hintergrund werden Kacheln Mica-artig durchscheinend (Kontrast >= 4,5:1 auch im schlechtesten Fall). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,10 @@ 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);
|
||||||
|
// 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 +121,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
|
||||||
@@ -151,11 +158,12 @@ export default function DashboardPage() {
|
|||||||
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
|
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
|
||||||
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
|
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
|
||||||
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
|
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
|
||||||
|
{isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCatalogOpen(true)}
|
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"
|
className="btn btn-primary shadow-lg"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -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-secondary shadow-lg"
|
||||||
|
>
|
||||||
|
<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 bottom-full right-0 z-30 mb-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.map((preset) => {
|
||||||
|
const css = dark ? preset.dark : 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -165,7 +165,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 +181,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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export function DashboardGrid({
|
|||||||
cols={COLS}
|
cols={COLS}
|
||||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||||
rowHeight={20}
|
rowHeight={20}
|
||||||
margin={[8, 8] as [number, number]}
|
margin={[12, 12] as [number, number]}
|
||||||
// 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={{
|
||||||
|
|||||||
@@ -507,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')}
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ 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 btn-icon h-10 w-10 shadow-lg ${isEditMode ? 'btn-primary' : 'btn-secondary text-muted-foreground'}`}
|
||||||
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')}
|
aria-label={isEditMode ? t('saveChanges') : t('editMode')}
|
||||||
title={isEditMode ? t('saveChanges') : t('editMode')}
|
title={isEditMode ? t('saveChanges') : t('editMode')}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -243,21 +243,21 @@ 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 p-3">
|
||||||
{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 */}
|
||||||
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">
|
<div className="grid grid-cols-[1fr_1.4fr_1fr] 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"
|
className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
onClick={showPrev}
|
onClick={showPrev}
|
||||||
>
|
>
|
||||||
{t('calendar.monthPrev')}
|
{t('calendar.monthPrev')}
|
||||||
</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 truncate rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(11px,2.8cqw,14px)] font-semibold leading-none text-foreground transition-colors hover:bg-muted"
|
||||||
title={t('calendar.monthToday')}
|
title={t('calendar.monthToday')}
|
||||||
onClick={showToday}
|
onClick={showToday}
|
||||||
>
|
>
|
||||||
@@ -265,7 +265,7 @@ 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"
|
className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
onClick={showNext}
|
onClick={showNext}
|
||||||
>
|
>
|
||||||
{t('calendar.monthNext')}
|
{t('calendar.monthNext')}
|
||||||
@@ -286,9 +286,9 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
const badges = buildDayBadges(day.events);
|
const badges = buildDayBadges(day.events);
|
||||||
const single = badges.length === 1;
|
const single = badges.length === 1;
|
||||||
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 flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded-md px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none',
|
||||||
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 ? 'cursor-default hover:bg-muted' : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -405,7 +405,7 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
) : (
|
) : (
|
||||||
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||||
{upcoming.map((event) => (
|
{upcoming.map((event) => (
|
||||||
<li key={event.id} className="flex items-start gap-2 rounded bg-muted/50 px-2 py-1">
|
<li key={event.id} className="flex items-start gap-2 rounded-md px-2 py-1 hover:bg-muted">
|
||||||
<span
|
<span
|
||||||
data-testid="event-color-dot"
|
data-testid="event-color-dot"
|
||||||
className="mt-1 h-2 w-2 shrink-0 rounded-full"
|
className="mt-1 h-2 w-2 shrink-0 rounded-full"
|
||||||
@@ -416,7 +416,7 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
|
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
|
||||||
{formatEventDate(event)}
|
{formatEventDate(event)}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-semibold text-foreground">
|
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-medium text-foreground">
|
||||||
{event.title}
|
{event.title}
|
||||||
</p>
|
</p>
|
||||||
{event.location && (
|
{event.location && (
|
||||||
@@ -438,7 +438,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,
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ 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">
|
||||||
{isEditMode ? (
|
{isEditMode ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -341,7 +341,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 +359,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 +370,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')}
|
||||||
@@ -675,7 +675,7 @@ function FavoriteTile({
|
|||||||
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="flex h-8 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) */}
|
||||||
@@ -685,7 +685,7 @@ function FavoriteTile({
|
|||||||
getFallbackLetter={getFallbackLetter}
|
getFallbackLetter={getFallbackLetter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="text-xs truncate">{fav.title}</span>
|
<span className="truncate text-sm">{fav.title}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Action buttons (edit mode only, D-04) */}
|
{/* Action buttons (edit mode only, D-04) */}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ 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">
|
||||||
<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 +180,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 +203,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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ 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">
|
||||||
<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 +186,7 @@ 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">
|
||||||
<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')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
|||||||
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-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';
|
: 'relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -45,7 +47,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 bg-muted/80"
|
||||||
title={t('dragHint')}
|
title={t('dragHint')}
|
||||||
data-testid="widget-drag-head"
|
data-testid="widget-drag-head"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -119,12 +119,12 @@ 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">
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
BACKGROUND_PRESETS,
|
||||||
|
loadDashboardBackground,
|
||||||
|
presetBackground,
|
||||||
|
saveDashboardBackground,
|
||||||
|
} from './dashboard-background';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dashboard-background (Design „Mosaik“, Prototyp)', () => {
|
||||||
|
it('ohne gespeicherte Wahl: kein Hintergrund', () => {
|
||||||
|
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichert je Benutzer getrennt', () => {
|
||||||
|
saveDashboardBackground('u1', { kind: 'preset', id: 'bloom' });
|
||||||
|
saveDashboardBackground('u2', { kind: 'image', imageId: 'img-1' });
|
||||||
|
expect(loadDashboardBackground('u1')).toEqual({ kind: 'preset', id: 'bloom' });
|
||||||
|
expect(loadDashboardBackground('u2')).toEqual({ kind: 'image', imageId: 'img-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwirft kaputte oder unbekannte Werte', () => {
|
||||||
|
window.localStorage.setItem('tessera.dashboardBackground.u1', '{kaputt');
|
||||||
|
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'tessera.dashboardBackground.u1',
|
||||||
|
JSON.stringify({ kind: 'preset', id: 'regenbogen' }),
|
||||||
|
);
|
||||||
|
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gesperrter Speicher wirft nicht', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
});
|
||||||
|
expect(() => saveDashboardBackground('u1', { kind: 'preset', id: 'mist' })).not.toThrow();
|
||||||
|
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jeder eingebaute Hintergrund hat eine helle und eine dunkle Fassung', () => {
|
||||||
|
for (const preset of BACKGROUND_PRESETS) {
|
||||||
|
expect(presetBackground(preset.id, false)).toBeTruthy();
|
||||||
|
expect(presetBackground(preset.id, true)).toBeTruthy();
|
||||||
|
expect(presetBackground(preset.id, false)).not.toBe(presetBackground(preset.id, true));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard-Hintergrund (Prototyp, Design „Mosaik“).
|
||||||
|
*
|
||||||
|
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
|
||||||
|
* drei abstrakte Motive in gedaempften Modulfarben — keine Regenbogen-
|
||||||
|
* verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern
|
||||||
|
* (`/dashboard/images`, keine API-Aenderung). Gespeichert wird vorerst im
|
||||||
|
* localStorage des Browsers, Schluessel je Benutzerkennung; ein Datenbankfeld
|
||||||
|
* kann folgen, wenn die Funktion bleibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BackgroundPresetId = 'mist' | 'sage' | 'bloom' | 'dunes' | 'mosaic';
|
||||||
|
|
||||||
|
export type DashboardBackground =
|
||||||
|
| { kind: 'none' }
|
||||||
|
| { kind: 'preset'; id: BackgroundPresetId }
|
||||||
|
| { kind: 'image'; imageId: string };
|
||||||
|
|
||||||
|
export const NO_BACKGROUND: DashboardBackground = { kind: 'none' };
|
||||||
|
|
||||||
|
function svgUrl(svg: string): string {
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice"';
|
||||||
|
|
||||||
|
function bloom(base: string, a: string, b: string, c: string): string {
|
||||||
|
return svgUrl(
|
||||||
|
`<svg ${VIEW}><defs><filter id="f" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="90"/></filter></defs>` +
|
||||||
|
`<rect width="1600" height="1000" fill="${base}"/>` +
|
||||||
|
`<g filter="url(#f)"><ellipse cx="1180" cy="240" rx="520" ry="360" fill="${a}"/>` +
|
||||||
|
`<ellipse cx="300" cy="820" rx="600" ry="340" fill="${b}"/>` +
|
||||||
|
`<ellipse cx="920" cy="930" rx="380" ry="240" fill="${c}"/></g>` +
|
||||||
|
// Feines Korn gegen Farbstufen im weichgezeichneten Verlauf.
|
||||||
|
`<filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>` +
|
||||||
|
`<rect width="1600" height="1000" filter="url(#n)" opacity="0.06"/></svg>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dunes(base: string, a: string, b: string, c: string): string {
|
||||||
|
return svgUrl(
|
||||||
|
`<svg ${VIEW}><rect width="1600" height="1000" fill="${base}"/>` +
|
||||||
|
`<path d="M0 600 C300 520 620 690 920 630 S1400 500 1600 580 L1600 1000 L0 1000Z" fill="${a}"/>` +
|
||||||
|
`<path d="M0 750 C360 680 660 820 1010 755 S1450 670 1600 730 L1600 1000 L0 1000Z" fill="${b}"/>` +
|
||||||
|
`<path d="M0 885 C400 835 800 930 1200 875 S1500 845 1600 865 L1600 1000 L0 1000Z" fill="${c}"/></svg>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mosaic(base: string, alpha: number, yellowAlpha: number): string {
|
||||||
|
const tiles: [number, number, string, number?][] = [
|
||||||
|
[1060, 120, '#2a8f8f'],
|
||||||
|
[1260, 120, '#7a5cc2'],
|
||||||
|
[1260, 320, '#3a78c9'],
|
||||||
|
[1060, 320, '#b37a1f'],
|
||||||
|
[1460, 320, '#c65436'],
|
||||||
|
[860, 520, '#3f8f5a'],
|
||||||
|
[1260, 520, '#3a78c9'],
|
||||||
|
[1460, 720, '#7a5cc2'],
|
||||||
|
[1060, 720, '#2a8f8f'],
|
||||||
|
[140, 760, '#c65436'],
|
||||||
|
];
|
||||||
|
const rects = tiles
|
||||||
|
.map(
|
||||||
|
([x, y, fill]) =>
|
||||||
|
`<rect x="${x}" y="${y}" width="170" height="170" rx="34" fill="${fill}" fill-opacity="${alpha}"/>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return svgUrl(
|
||||||
|
`<svg ${VIEW}><rect width="1600" height="1000" fill="${base}"/>${rects}` +
|
||||||
|
`<rect x="1060" y="520" width="170" height="170" rx="34" fill="#ffed00" fill-opacity="${yellowAlpha}" transform="rotate(12 1145 605)"/></svg>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundPreset {
|
||||||
|
id: BackgroundPresetId;
|
||||||
|
/** Uebersetzungsschluessel unter `dashboard.background.presets`. */
|
||||||
|
labelKey: string;
|
||||||
|
light: string;
|
||||||
|
dark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BACKGROUND_PRESETS: BackgroundPreset[] = [
|
||||||
|
{ id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' },
|
||||||
|
{ id: 'sage', labelKey: 'sage', light: '#e3ebe6', dark: '#1c2521' },
|
||||||
|
{
|
||||||
|
id: 'bloom',
|
||||||
|
labelKey: 'bloom',
|
||||||
|
light: bloom('#e8ecf3', '#b7c7e6', '#bcdcd7', '#d4cbe9'),
|
||||||
|
dark: bloom('#1b1f28', '#2a3a58', '#21403c', '#352e4d'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dunes',
|
||||||
|
labelKey: 'dunes',
|
||||||
|
light: dunes('#e6ebef', '#d3dde3', '#c1cfd8', '#aebfca'),
|
||||||
|
dark: dunes('#1a1f25', '#212930', '#28323a', '#303c45'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mosaic',
|
||||||
|
labelKey: 'mosaic',
|
||||||
|
light: mosaic('#eceff3', 0.16, 0.35),
|
||||||
|
dark: mosaic('#1b1f26', 0.24, 0.3),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function presetBackground(id: BackgroundPresetId, dark: boolean): string | null {
|
||||||
|
const preset = BACKGROUND_PRESETS.find((p) => p.id === id);
|
||||||
|
if (!preset) return null;
|
||||||
|
return dark ? preset.dark : preset.light;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(userId: string): string {
|
||||||
|
return `tessera.dashboardBackground.${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValid(value: unknown): value is DashboardBackground {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
if (v.kind === 'none') return true;
|
||||||
|
if (v.kind === 'preset') return BACKGROUND_PRESETS.some((p) => p.id === v.id);
|
||||||
|
if (v.kind === 'image') return typeof v.imageId === 'string' && v.imageId !== '';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die Wahl; jeder Fehler (privates Fenster, kaputter Wert) ergibt „kein Hintergrund“. */
|
||||||
|
export function loadDashboardBackground(userId: string): DashboardBackground {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey(userId));
|
||||||
|
if (!raw) return NO_BACKGROUND;
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return isValid(parsed) ? parsed : NO_BACKGROUND;
|
||||||
|
} catch {
|
||||||
|
return NO_BACKGROUND;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDashboardBackground(userId: string, value: DashboardBackground): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey(userId), JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// Speicher gesperrt — die Wahl gilt dann nur fuer diese Sitzung.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user