feat(notifications): settings notification preferences
This commit is contained in:
@@ -3,7 +3,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
|
||||
import {
|
||||
Bell,
|
||||
Brain,
|
||||
FileCog,
|
||||
Github,
|
||||
ServerCog,
|
||||
Shield,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
@@ -12,6 +20,7 @@ const settingsItems = [
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
||||
{ href: '/settings/notifications', label: 'Notifications', icon: Bell },
|
||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NotificationPreferencesPanel } from '@/components/settings/notification-preferences-panel';
|
||||
|
||||
const SettingsNotificationsPage = () => {
|
||||
return (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Notifications</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Choose which notifications also reach you by email. In-app
|
||||
notifications always show in the bell; these toggles only control the
|
||||
email copies.
|
||||
</p>
|
||||
</div>
|
||||
<NotificationPreferencesPanel />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsNotificationsPage;
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, Label, Switch } from '@spoon/ui';
|
||||
|
||||
type PreferenceField =
|
||||
| 'emailEnabled'
|
||||
| 'emailMaintenance'
|
||||
| 'emailAgentTurnFinished'
|
||||
| 'emailAgentNeedsInput'
|
||||
| 'emailSyncFailed'
|
||||
| 'emailConnectionReauth';
|
||||
|
||||
const perKindToggles: {
|
||||
field: Exclude<PreferenceField, 'emailEnabled'>;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
field: 'emailMaintenance',
|
||||
label: 'Maintenance threads',
|
||||
description: 'A maintenance review thread opens for one of your Spoons.',
|
||||
},
|
||||
{
|
||||
field: 'emailAgentTurnFinished',
|
||||
label: 'Agent finished a turn',
|
||||
description: 'An agent finishes a turn and changes are ready to review.',
|
||||
},
|
||||
{
|
||||
field: 'emailAgentNeedsInput',
|
||||
label: 'Agent needs your input',
|
||||
description: 'An agent pauses and needs your input to continue.',
|
||||
},
|
||||
{
|
||||
field: 'emailSyncFailed',
|
||||
label: 'A sync fails',
|
||||
description: 'A scheduled sync with upstream fails for one of your Spoons.',
|
||||
},
|
||||
{
|
||||
field: 'emailConnectionReauth',
|
||||
label: 'A connection needs re-authentication',
|
||||
description: 'A git connection is revoked and needs to be reconnected.',
|
||||
},
|
||||
];
|
||||
|
||||
export const NotificationPreferencesPanel = () => {
|
||||
// Render directly from the reactive query: after a save resolves, the query
|
||||
// re-emits the stored value, so there is no local copy to fall out of sync.
|
||||
const prefs = useQuery(api.notifications.getPreferences);
|
||||
const updatePreferences = useMutation(api.notifications.updatePreferences);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const onToggle = async (field: PreferenceField, next: boolean) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updatePreferences({ [field]: next });
|
||||
toast.success('Preferences saved.');
|
||||
} catch {
|
||||
toast.error('Could not save preferences.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (prefs === undefined) {
|
||||
return (
|
||||
<Card className='p-4 shadow-none'>
|
||||
<p className='text-muted-foreground text-sm'>Loading preferences…</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const perKindDisabled = saving || !prefs.emailEnabled;
|
||||
|
||||
return (
|
||||
<Card className='divide-border divide-y p-0 shadow-none'>
|
||||
<div className='flex items-center justify-between gap-4 p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<Label className='text-sm font-medium'>Email notifications</Label>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Send email for your notifications. Turn this off to silence every
|
||||
kind below.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label='Email notifications'
|
||||
checked={prefs.emailEnabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(next) => void onToggle('emailEnabled', next)}
|
||||
/>
|
||||
</div>
|
||||
{perKindToggles.map(({ field, label, description }) => (
|
||||
<div
|
||||
key={field}
|
||||
className='flex items-center justify-between gap-4 p-4'
|
||||
>
|
||||
<div className='space-y-0.5'>
|
||||
<Label className='text-sm font-medium'>{label}</Label>
|
||||
<p className='text-muted-foreground text-sm'>{description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={label}
|
||||
checked={prefs.emailEnabled && prefs[field]}
|
||||
disabled={perKindDisabled}
|
||||
onCheckedChange={(next) => void onToggle(field, next)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user