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:
parent
ca1cd14ed1
commit
0d2d321fbb
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user