- 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
182 lines
5.7 KiB
TypeScript
182 lines
5.7 KiB
TypeScript
'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>
|
|
);
|
|
}
|