feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank
- Spalte User.dashboardBackground (JSONB) samt Migration - PATCH /users/me/dashboard-background, geprueft mit parseDashboardBackground aus @tessera/shared (Allowlist, UUID-Bildkennung) - getMe liefert dashboardBackground normalisiert neben accentColor - Web liest die Wahl aus dem Auth-Store, speichert ueber die Server-Aktion, alte localStorage-Wahl wird einmalig uebernommen - Hinweistext: gilt auf jedem Geraet Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,13 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { updateDashboardBackgroundAction } from '@/lib/auth-actions';
|
||||
import {
|
||||
BACKGROUND_PRESETS,
|
||||
type DashboardBackground,
|
||||
loadDashboardBackground,
|
||||
NO_BACKGROUND,
|
||||
presetBackground,
|
||||
saveDashboardBackground,
|
||||
takeLegacyDashboardBackground,
|
||||
} from '@/lib/dashboard-background';
|
||||
import {
|
||||
type DashboardImageMeta,
|
||||
@@ -17,23 +17,61 @@ import {
|
||||
fetchDashboardImages,
|
||||
uploadDashboardImage,
|
||||
} from '@/lib/dashboard-images-api';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
/** Wahl des Hintergrunds fuer den angemeldeten Benutzer (localStorage, Prototyp). */
|
||||
export function useDashboardBackground(userId: string | null | undefined) {
|
||||
const [background, setBackground] = useState<DashboardBackground>(NO_BACKGROUND);
|
||||
/** Schreibt die Wahl in den Auth-Store, sofern noch derselbe Benutzer angemeldet ist. */
|
||||
function setStoredBackground(userId: string, value: DashboardBackground | null) {
|
||||
const current = useAuthStore.getState().user;
|
||||
if (!current || current.id !== userId) return;
|
||||
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) setBackground(loadDashboardBackground(userId));
|
||||
}, [userId]);
|
||||
/**
|
||||
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
|
||||
*
|
||||
* Gelesen aus dem Auth-Store (Sitzungsantwort, `null` = nie gewaehlt =
|
||||
* kein Hintergrund). `choose()` setzt den Store sofort und speichert ueber
|
||||
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
|
||||
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
|
||||
* wird sie genau einmal je Benutzerkennung uebernommen.
|
||||
*/
|
||||
export function useDashboardBackground() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = user?.id ?? null;
|
||||
const stored = user?.dashboardBackground ?? null;
|
||||
const background = stored ?? NO_BACKGROUND;
|
||||
const migratedFor = useRef<string | null>(null);
|
||||
|
||||
const save = useCallback(
|
||||
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
|
||||
setStoredBackground(id, value);
|
||||
const revert = () => setStoredBackground(id, previous);
|
||||
updateDashboardBackgroundAction(value)
|
||||
.then((result) => {
|
||||
if (!result.success) revert();
|
||||
})
|
||||
.catch(revert);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const choose = useCallback(
|
||||
(value: DashboardBackground) => {
|
||||
setBackground(value);
|
||||
if (userId) saveDashboardBackground(userId, value);
|
||||
if (!userId) return;
|
||||
save(userId, value, stored);
|
||||
},
|
||||
[userId],
|
||||
[userId, stored, save],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || migratedFor.current === userId) return;
|
||||
migratedFor.current = userId;
|
||||
// Auch bei gesetztem Server-Wert auslesen: der alte Schluessel wird so
|
||||
// aufgeraeumt, uebernommen wird aber nur, wenn noch nie gewaehlt wurde.
|
||||
const legacy = takeLegacyDashboardBackground(userId);
|
||||
if (legacy && stored === null) save(userId, legacy, null);
|
||||
}, [userId, stored, save]);
|
||||
|
||||
return { background, choose };
|
||||
}
|
||||
|
||||
@@ -164,13 +202,17 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
>
|
||||
<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'
|
||||
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'}`}>
|
||||
<span
|
||||
className={`truncate text-xs ${selected ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -187,7 +229,18 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
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">
|
||||
<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" />
|
||||
@@ -206,8 +259,25 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
|
||||
<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>,
|
||||
{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;
|
||||
@@ -247,7 +317,20 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
{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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user