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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user