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
This commit is contained in:
parent
5aef2559dd
commit
577e236eb5
2 changed files with 283 additions and 0 deletions
182
components/mobile/profile/ProfileNotificationMatrix.tsx
Normal file
182
components/mobile/profile/ProfileNotificationMatrix.tsx
Normal file
|
|
@ -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<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export function ProfileNotificationMatrix() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<SubscriptionData | null>(null);
|
||||
const [matrix, setMatrix] = useState<Record<string, Record<string, boolean>>>({});
|
||||
// Track per-cell debounce timers keyed by "eventKey::channelType"
|
||||
const pendingTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(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 <ProfileSectionSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-0">
|
||||
<CardTitle className="text-xl font-semibold">Notifications</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which events trigger a personal notification.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-4">
|
||||
{!data || data.channelTypes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure a Teams or ntfy channel below to enable personal notifications.
|
||||
</p>
|
||||
) : data.channelTypes.length === 1 ? (
|
||||
/* Single channel type — simple list, no header row */
|
||||
<div className="divide-y">
|
||||
{data.eventKeys.map((ek) => {
|
||||
const channelType = data.channelTypes[0];
|
||||
const checked = matrix[ek.key]?.[channelType] ?? true;
|
||||
return (
|
||||
<div
|
||||
key={ek.key}
|
||||
className="flex items-center justify-between min-h-[44px] py-3 gap-3"
|
||||
>
|
||||
<span className="text-sm flex-1">{ek.displayLabel}</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(val) => handleToggle(ek.key, channelType, val)}
|
||||
aria-label={ek.displayLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Multiple channel types — grid with header row */
|
||||
<div>
|
||||
{/* Header row */}
|
||||
<div
|
||||
className="grid mb-1"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
'minmax(0,1fr) ' + 'repeat(' + data.channelTypes.length + ', 4rem)',
|
||||
}}
|
||||
>
|
||||
<span />
|
||||
{data.channelTypes.map((ct) => (
|
||||
<span key={ct} className="text-xs text-center capitalize text-muted-foreground">
|
||||
{ct}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Per event-key rows */}
|
||||
{data.eventKeys.map((ek) => (
|
||||
<div
|
||||
key={ek.key}
|
||||
className="grid min-h-[44px] items-center py-3 border-t first:border-t-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
'minmax(0,1fr) ' + 'repeat(' + data.channelTypes.length + ', 4rem)',
|
||||
}}
|
||||
>
|
||||
<span className="text-sm">{ek.displayLabel}</span>
|
||||
{data.channelTypes.map((ct) => {
|
||||
const checked = matrix[ek.key]?.[ct] ?? true;
|
||||
return (
|
||||
<div key={ct} className="flex items-center justify-center">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(val) => handleToggle(ek.key, ct, val)}
|
||||
aria-label={`${ek.displayLabel} — ${ct}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
101
components/mobile/profile/ProfileThemeSection.tsx
Normal file
101
components/mobile/profile/ProfileThemeSection.tsx
Normal file
|
|
@ -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<ThemeValue>('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 (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-4 pb-0">
|
||||
<CardTitle className="text-xl font-semibold">Theme</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-4">
|
||||
<div role="radiogroup" aria-label="Theme selection">
|
||||
{THEME_OPTIONS.map(({ value, label, Icon }) => {
|
||||
const isActive = currentTheme === value;
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
tabIndex={0}
|
||||
onClick={() => 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' : ''}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-sm flex-1">{label}</span>
|
||||
{isActive && <Check className="w-4 h-4 shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue