From 577e236eb5d35a0af5f78bff25436fbef998196b Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 10 May 2026 07:40:53 -0400 Subject: [PATCH] feat(09-04): ProfileThemeSection + ProfileNotificationMatrix - ProfileThemeSection: 3-option radio rows (Light/Dark/System), immediate setTheme + PUT /api/me/theme, rollback on error - ProfileNotificationMatrix: skeleton loading, empty state, single/multi-column switch grid, 400ms debounced PUT per cell --- .../profile/ProfileNotificationMatrix.tsx | 182 ++++++++++++++++++ .../mobile/profile/ProfileThemeSection.tsx | 101 ++++++++++ 2 files changed, 283 insertions(+) create mode 100644 components/mobile/profile/ProfileNotificationMatrix.tsx create mode 100644 components/mobile/profile/ProfileThemeSection.tsx diff --git a/components/mobile/profile/ProfileNotificationMatrix.tsx b/components/mobile/profile/ProfileNotificationMatrix.tsx new file mode 100644 index 0000000..ad7c51c --- /dev/null +++ b/components/mobile/profile/ProfileNotificationMatrix.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { toast } from 'sonner'; +import ProfileSectionSkeleton from './ProfileSectionSkeleton'; + +interface EventKey { + key: string; + displayLabel: string; + description: string | null; + sortOrder: number; +} + +interface SubscriptionData { + eventKeys: EventKey[]; + channelTypes: string[]; + matrix: Record>; +} + +export function ProfileNotificationMatrix() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [matrix, setMatrix] = useState>>({}); + // Track per-cell debounce timers keyed by "eventKey::channelType" + const pendingTimers = useRef>>(new Map()); + + useEffect(() => { + fetch('/api/me/notification-subscriptions') + .then((r) => r.json()) + .then((fetched: SubscriptionData) => { + setData(fetched); + setMatrix(fetched.matrix); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, []); + + function handleToggle(eventKey: string, channelType: string, newValue: boolean) { + // Optimistic update + setMatrix((prev) => ({ + ...prev, + [eventKey]: { + ...(prev[eventKey] ?? {}), + [channelType]: newValue, + }, + })); + + const cellKey = `${eventKey}::${channelType}`; + + // Clear any existing debounce timer for this cell + const existing = pendingTimers.current.get(cellKey); + if (existing) clearTimeout(existing); + + const timer = setTimeout(async () => { + pendingTimers.current.delete(cellKey); + try { + const res = await fetch('/api/me/notification-subscriptions', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + event_key: eventKey, + channel_type: channelType, + enabled: newValue, + }), + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + toast.success('Preference saved'); + } catch { + // Revert on error + setMatrix((prev) => ({ + ...prev, + [eventKey]: { + ...(prev[eventKey] ?? {}), + [channelType]: !newValue, + }, + })); + toast.error("Couldn't save preference"); + } + }, 400); + + pendingTimers.current.set(cellKey, timer); + } + + if (loading) { + return ; + } + + return ( + + + Notifications + + Choose which events trigger a personal notification. + + + + {!data || data.channelTypes.length === 0 ? ( +

+ Configure a Teams or ntfy channel below to enable personal notifications. +

+ ) : data.channelTypes.length === 1 ? ( + /* Single channel type — simple list, no header row */ +
+ {data.eventKeys.map((ek) => { + const channelType = data.channelTypes[0]; + const checked = matrix[ek.key]?.[channelType] ?? true; + return ( +
+ {ek.displayLabel} + handleToggle(ek.key, channelType, val)} + aria-label={ek.displayLabel} + /> +
+ ); + })} +
+ ) : ( + /* Multiple channel types — grid with header row */ +
+ {/* Header row */} +
+ + {data.channelTypes.map((ct) => ( + + {ct} + + ))} +
+ {/* Per event-key rows */} + {data.eventKeys.map((ek) => ( +
+ {ek.displayLabel} + {data.channelTypes.map((ct) => { + const checked = matrix[ek.key]?.[ct] ?? true; + return ( +
+ handleToggle(ek.key, ct, val)} + aria-label={`${ek.displayLabel} — ${ct}`} + /> +
+ ); + })} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/mobile/profile/ProfileThemeSection.tsx b/components/mobile/profile/ProfileThemeSection.tsx new file mode 100644 index 0000000..b433f89 --- /dev/null +++ b/components/mobile/profile/ProfileThemeSection.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTheme } from 'next-themes'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Sun, Moon, Monitor, Check } from 'lucide-react'; +import { toast } from 'sonner'; + +type ThemeValue = 'light' | 'dark' | 'system'; + +interface ThemeOption { + value: ThemeValue; + label: string; + Icon: React.ComponentType<{ className?: string }>; +} + +const THEME_OPTIONS: ThemeOption[] = [ + { value: 'light', label: 'Light', Icon: Sun }, + { value: 'dark', label: 'Dark', Icon: Moon }, + { value: 'system', label: 'System', Icon: Monitor }, +]; + +export function ProfileThemeSection() { + const { theme, setTheme } = useTheme(); + // Track the server-canonical value separately to allow rollback on error + const [serverTheme, setServerTheme] = useState('system'); + + // On mount: fetch server-canonical theme and reconcile with next-themes + useEffect(() => { + fetch('/api/me/theme') + .then((r) => r.json()) + .then((data: { theme: ThemeValue }) => { + setServerTheme(data.theme); + if (data.theme !== theme) { + setTheme(data.theme); + } + }) + .catch(() => { + // Silently fall back to current next-themes value + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleSelect(value: ThemeValue) { + const previous = (theme as ThemeValue) ?? serverTheme; + setTheme(value); + + try { + const res = await fetch('/api/me/theme', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ theme: value }), + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + setServerTheme(value); + toast.success('Theme updated'); + } catch { + setTheme(previous); + toast.error('Failed to update theme'); + } + } + + const currentTheme = (theme as ThemeValue) ?? 'system'; + + return ( + + + Theme + + +
+ {THEME_OPTIONS.map(({ value, label, Icon }) => { + const isActive = currentTheme === value; + return ( +
handleSelect(value)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelect(value); + } + }} + className={`min-h-[44px] flex items-center gap-3 px-2 py-3 rounded-md cursor-pointer hover:bg-accent transition-colors ${isActive ? 'text-primary' : ''}`} + > + + {label} + {isActive && } +
+ ); + })} +
+
+
+ ); +}