Restructure Integrations card: inline title, ntfy sub-section with collapsible config

- Integrations card now has inline icon+title (no description), future-proofed
  for additional integrations as sub-sections
- ntfy is its own sub-section with Bell icon + "ntfy Push Notifications" header
- Connection settings (URL, topic, token) collapse into a "Configure ntfy
  connection" disclosure after saving, keeping the UI tidy
- Notification types and test/save buttons remain visible when enabled
- First-time setup shows connection fields expanded; they collapse on save

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-25 17:24:40 +08:00
parent ca1cd14ed1
commit 0d2d321fbb
2 changed files with 176 additions and 150 deletions

View File

@ -3,10 +3,12 @@ import { toast } from 'sonner';
import { import {
AlertTriangle, AlertTriangle,
Bell, Bell,
ChevronDown,
Eye, Eye,
EyeOff, EyeOff,
Loader2, Loader2,
Send, Send,
Settings2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -14,8 +16,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Settings } from '@/types'; import type { Settings } from '@/types';
@ -47,6 +49,10 @@ export default function NtfySettingsSection({ settings, updateSettings }: NtfySe
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isTestingNtfy, setIsTestingNtfy] = useState(false); const [isTestingNtfy, setIsTestingNtfy] = useState(false);
// Connection config is collapsed once server+topic are saved
const isConfigured = !!(settings?.ntfy_server_url && settings?.ntfy_topic);
const [configExpanded, setConfigExpanded] = useState(!isConfigured);
// Sync from settings on initial load // Sync from settings on initial load
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
@ -62,6 +68,7 @@ export default function NtfySettingsSection({ settings, updateSettings }: NtfySe
setTodoLeadDays(settings.ntfy_todo_lead_days ?? 0); setTodoLeadDays(settings.ntfy_todo_lead_days ?? 0);
setProjectsEnabled(settings.ntfy_projects_enabled ?? false); setProjectsEnabled(settings.ntfy_projects_enabled ?? false);
setProjectLeadDays(settings.ntfy_project_lead_days ?? 0); setProjectLeadDays(settings.ntfy_project_lead_days ?? 0);
setConfigExpanded(!(settings.ntfy_server_url && settings.ntfy_topic));
}, [settings?.id]); }, [settings?.id]);
const ntfyHasToken = settings?.ntfy_has_token ?? false; const ntfyHasToken = settings?.ntfy_has_token ?? false;
@ -93,6 +100,10 @@ export default function NtfySettingsSection({ settings, updateSettings }: NtfySe
await updateSettings(updates); await updateSettings(updates);
setNtfyToken(''); setNtfyToken('');
setTokenCleared(false); setTokenCleared(false);
// Collapse connection settings after successful save if configured
if (ntfyServerUrl.trim() && ntfyTopic.trim()) {
setConfigExpanded(false);
}
toast.success('Notification settings saved'); toast.success('Notification settings saved');
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, 'Failed to save notification settings')); toast.error(getErrorMessage(error, 'Failed to save notification settings'));
@ -119,51 +130,56 @@ export default function NtfySettingsSection({ settings, updateSettings }: NtfySe
}; };
return ( return (
<Card> <div className="space-y-4">
<CardHeader> {/* ntfy sub-section header */}
<div className="flex items-center gap-3"> <div className="flex items-center justify-between">
<div className="p-1.5 rounded-md bg-orange-500/10"> <div className="flex items-center gap-2.5">
<Bell className="h-4 w-4 text-orange-400" aria-hidden="true" /> <Bell className="h-4 w-4 text-orange-400" aria-hidden="true" />
</div> <p className="text-sm font-medium">ntfy Push Notifications</p>
<div>
<CardTitle>Integrations</CardTitle>
<CardDescription>Push notifications via ntfy</CardDescription>
</div>
</div> </div>
</CardHeader> <Switch checked={ntfyEnabled} onCheckedChange={setNtfyEnabled} />
<CardContent className="space-y-5"> </div>
{/* Master toggle */} {ntfyEnabled && (
<div className="flex items-center justify-between"> <>
<div> {/* Warning banner */}
<p className="text-sm font-medium">Enable Push Notifications</p> {isMisconfigured && (
<p className="text-xs text-muted-foreground mt-0.5"> <div
Send alerts to your ntfy server for reminders, todos, and events role="alert"
</p> className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2"
</div> >
<Switch checked={ntfyEnabled} onCheckedChange={setNtfyEnabled} /> <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
</div> <p className="text-xs text-amber-400">
Server URL and topic are required to send notifications
</p>
</div>
)}
{ntfyEnabled && ( {/* Connection config — collapsible when already configured */}
<> {isConfigured && !configExpanded ? (
<Separator /> <button
type="button"
{/* Warning banner */} onClick={() => setConfigExpanded(true)}
{isMisconfigured && ( className="flex items-center gap-2 w-full rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-card-elevated transition-colors"
<div >
role="alert" <Settings2 className="h-3.5 w-3.5" />
className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2" <span className="flex-1 text-left">Configure ntfy connection</span>
> <ChevronDown className="h-3.5 w-3.5" />
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" /> </button>
<p className="text-xs text-amber-400"> ) : (
Server URL and topic are required to send notifications <div className="space-y-3 rounded-md border border-border p-3">
</p> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Connection</p>
{isConfigured && (
<button
type="button"
onClick={() => setConfigExpanded(false)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Collapse
</button>
)}
</div> </div>
)}
{/* Connection config */}
<div className="space-y-3">
<p className="text-sm font-medium">Connection</p>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ntfy_server_url">Server URL</Label> <Label htmlFor="ntfy_server_url">Server URL</Label>
<Input <Input
@ -193,7 +209,6 @@ export default function NtfySettingsSection({ settings, updateSettings }: NtfySe
value={ntfyToken} value={ntfyToken}
onChange={(e) => { onChange={(e) => {
setNtfyToken(e.target.value); setNtfyToken(e.target.value);
// If user starts typing after clearing, undo the cleared flag
if (tokenCleared && e.target.value) setTokenCleared(false); if (tokenCleared && e.target.value) setTokenCleared(false);
}} }}
placeholder={ placeholder={
@ -206,7 +221,6 @@ export default function NtfySettingsSection({ settings, updateSettings }: NtfySe
className="pr-16" className="pr-16"
/> />
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1 items-center"> <div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1 items-center">
{/* Clear button — only when a token is saved and field is empty and not yet cleared */}
{ntfyHasToken && !ntfyToken && !tokenCleared && ( {ntfyHasToken && !ntfyToken && !tokenCleared && (
<button <button
type="button" type="button"
@ -236,121 +250,120 @@ export default function NtfySettingsSection({ settings, updateSettings }: NtfySe
)} )}
</div> </div>
</div> </div>
)}
<Separator /> <Separator />
{/* Per-type notification toggles */} {/* Per-type notification toggles */}
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm font-medium">Notification Types</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Notification Types</p>
{/* Event reminders */} {/* Event reminders */}
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
<Switch checked={eventsEnabled} onCheckedChange={setEventsEnabled} /> <Switch checked={eventsEnabled} onCheckedChange={setEventsEnabled} />
<p className="text-sm">Event reminders</p> <p className="text-sm">Event reminders</p>
</div>
{eventsEnabled && (
<Select
value={String(eventLeadMinutes)}
onChange={(e) => setEventLeadMinutes(Number(e.target.value))}
className="h-8 w-36 text-xs"
>
<option value="5">5 min before</option>
<option value="10">10 min before</option>
<option value="15">15 min before</option>
<option value="30">30 min before</option>
<option value="60">1 hour before</option>
</Select>
)}
</div>
{/* Reminder alerts */}
<div className="flex items-center gap-3">
<Switch checked={remindersEnabled} onCheckedChange={setRemindersEnabled} />
<p className="text-sm">Reminder alerts</p>
</div>
{/* Todo due dates */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Switch checked={todosEnabled} onCheckedChange={setTodosEnabled} />
<p className="text-sm">Todo due dates</p>
</div>
{todosEnabled && (
<Select
value={String(todoLeadDays)}
onChange={(e) => setTodoLeadDays(Number(e.target.value))}
className="h-8 w-36 text-xs"
>
<option value="0">Same day</option>
<option value="1">1 day before</option>
<option value="2">2 days before</option>
<option value="3">3 days before</option>
<option value="7">1 week before</option>
</Select>
)}
</div>
{/* Project deadlines */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Switch checked={projectsEnabled} onCheckedChange={setProjectsEnabled} />
<p className="text-sm">Project deadlines</p>
</div>
{projectsEnabled && (
<Select
value={String(projectLeadDays)}
onChange={(e) => setProjectLeadDays(Number(e.target.value))}
className="h-8 w-36 text-xs"
>
<option value="0">Same day</option>
<option value="1">1 day before</option>
<option value="2">2 days before</option>
<option value="3">3 days before</option>
<option value="7">1 week before</option>
</Select>
)}
</div> </div>
{eventsEnabled && (
<Select
value={String(eventLeadMinutes)}
onChange={(e) => setEventLeadMinutes(Number(e.target.value))}
className="h-8 w-36 text-xs"
>
<option value="5">5 min before</option>
<option value="10">10 min before</option>
<option value="15">15 min before</option>
<option value="30">30 min before</option>
<option value="60">1 hour before</option>
</Select>
)}
</div> </div>
<Separator /> {/* Reminder alerts */}
<div className="flex items-center gap-3">
{/* Test + Save */} <Switch checked={remindersEnabled} onCheckedChange={setRemindersEnabled} />
<div className="flex items-center justify-between flex-wrap gap-3"> <p className="text-sm">Reminder alerts</p>
<Button
variant="outline"
size="sm"
onClick={handleNtfyTest}
disabled={!ntfyEnabled || !ntfyServerUrl.trim() || !ntfyTopic.trim() || isTestingNtfy}
className="gap-2"
>
{isTestingNtfy ? (
<><Loader2 className="h-4 w-4 animate-spin" />Sending...</>
) : (
<><Send className="h-4 w-4" />Send Test Notification</>
)}
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
{isSaving ? (
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
) : (
'Save Notifications Config'
)}
</Button>
</div> </div>
</>
)}
{/* Save button when disabled — still persist the toggle state */} {/* Todo due dates */}
{!ntfyEnabled && ( <div className="flex items-center justify-between gap-3">
<div className="flex justify-end"> <div className="flex items-center gap-3 flex-1 min-w-0">
<Switch checked={todosEnabled} onCheckedChange={setTodosEnabled} />
<p className="text-sm">Todo due dates</p>
</div>
{todosEnabled && (
<Select
value={String(todoLeadDays)}
onChange={(e) => setTodoLeadDays(Number(e.target.value))}
className="h-8 w-36 text-xs"
>
<option value="0">Same day</option>
<option value="1">1 day before</option>
<option value="2">2 days before</option>
<option value="3">3 days before</option>
<option value="7">1 week before</option>
</Select>
)}
</div>
{/* Project deadlines */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Switch checked={projectsEnabled} onCheckedChange={setProjectsEnabled} />
<p className="text-sm">Project deadlines</p>
</div>
{projectsEnabled && (
<Select
value={String(projectLeadDays)}
onChange={(e) => setProjectLeadDays(Number(e.target.value))}
className="h-8 w-36 text-xs"
>
<option value="0">Same day</option>
<option value="1">1 day before</option>
<option value="2">2 days before</option>
<option value="3">3 days before</option>
<option value="7">1 week before</option>
</Select>
)}
</div>
</div>
<Separator />
{/* Test + Save */}
<div className="flex items-center justify-between flex-wrap gap-3">
<Button
variant="outline"
size="sm"
onClick={handleNtfyTest}
disabled={!ntfyEnabled || !ntfyServerUrl.trim() || !ntfyTopic.trim() || isTestingNtfy}
className="gap-2"
>
{isTestingNtfy ? (
<><Loader2 className="h-4 w-4 animate-spin" />Sending...</>
) : (
<><Send className="h-4 w-4" />Send Test Notification</>
)}
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2"> <Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
{isSaving ? <><Loader2 className="h-4 w-4 animate-spin" />Saving</> : 'Save'} {isSaving ? (
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
) : (
'Save Notifications Config'
)}
</Button> </Button>
</div> </div>
)} </>
)}
</CardContent> {/* Save button when disabled — still persist the toggle state */}
</Card> {!ntfyEnabled && (
<div className="flex justify-end">
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
{isSaving ? <><Loader2 className="h-4 w-4 animate-spin" />Saving</> : 'Save'}
</Button>
</div>
)}
</div>
); );
} }

View File

@ -13,6 +13,7 @@ import {
Search, Search,
Loader2, Loader2,
Shield, Shield,
Blocks,
} from 'lucide-react'; } from 'lucide-react';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@ -527,8 +528,20 @@ export default function SettingsPage() {
{/* Authentication (TOTP + password change) */} {/* Authentication (TOTP + password change) */}
<TotpSetupSection /> <TotpSetupSection />
{/* Integrations (ntfy push notifications) */} {/* Integrations */}
<NtfySettingsSection settings={settings} updateSettings={updateSettings} /> <Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-orange-500/10">
<Blocks className="h-4 w-4 text-orange-400" aria-hidden="true" />
</div>
<CardTitle>Integrations</CardTitle>
</div>
</CardHeader>
<CardContent>
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
</CardContent>
</Card>
</div> </div>