feat: add Notification Settings Modal and Stack Alert Sheet components

- Implemented NotificationSettingsModal for configuring notification agents (Discord, Slack, Webhook) and global settings.
- Added StackAlertSheet for managing stack-specific alert rules with metrics, thresholds, and cooldowns.
- Introduced reusable UI components: DropdownMenu, Popover, Select, Sheet, Slider, and Switch.
- Enhanced Tabs component by removing unnecessary client directive.
This commit is contained in:
SaelixCode
2026-02-26 23:09:47 -05:00
parent ba7853b2b3
commit c6eb9d76e7
19 changed files with 2517 additions and 7 deletions
+126 -5
View File
@@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink } from 'lucide-react';
import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink, Bell, Settings, MoreVertical, BellRing } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
@@ -23,7 +23,10 @@ import { ScrollArea } from './ui/scroll-area';
import { Skeleton } from './ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
import { NotificationSettingsModal } from './NotificationSettingsModal';
import { StackAlertSheet } from './StackAlertSheet';
interface ContainerInfo {
Id: string;
Names: string[];
@@ -82,6 +85,17 @@ export default function EditorLayout() {
// Maintenance modal state
const [maintenanceModalOpen, setMaintenanceModalOpen] = useState(false);
// Notifications & Settings state
const [notifications, setNotifications] = useState<any[]>([]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
const [alertSheetStack, setAlertSheetStack] = useState('');
const openAlertSheet = (stackName: string) => {
setAlertSheetStack(stackName);
setAlertSheetOpen(true);
};
// Theme toggle effect
useEffect(() => {
const html = document.documentElement;
@@ -124,8 +138,28 @@ export default function EditorLayout() {
useEffect(() => {
refreshStacks();
fetchNotifications();
const notificationInterval = setInterval(fetchNotifications, 30000);
return () => clearInterval(notificationInterval);
}, []);
const fetchNotifications = async () => {
try {
const res = await apiFetch('/notifications/history');
if (res.ok) {
const data = await res.json();
setNotifications(data);
}
} catch (e) { }
};
const markAllRead = async () => {
try {
await apiFetch('/notifications/read', { method: 'POST' });
fetchNotifications();
} catch (e) { }
};
useEffect(() => {
const wsMap: Record<string, WebSocket> = {};
(containers || []).forEach(container => {
@@ -576,7 +610,7 @@ export default function EditorLayout() {
key={file}
value={file}
onSelect={() => loadFile(file)}
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
>
<div className="flex items-center gap-2 w-full">
<div
@@ -584,7 +618,23 @@ export default function EditorLayout() {
stackStatuses[file] === 'exited' ? 'bg-red-500' : 'bg-gray-400'
}`}
/>
{getDisplayName(file)}
<span className="flex-1 truncate">{getDisplayName(file)}</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openAlertSheet(file)}>
<BellRing className="h-4 w-4 mr-2" />
Configure Alerts
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CommandItem>
))
@@ -640,6 +690,65 @@ export default function EditorLayout() {
<Brush className="w-4 h-4 mr-2" />
Janitor
</Button>
{/* Settings Modal Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setSettingsModalOpen(true)}
title="Notification Settings"
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
{/* Notifications Popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
<Bell className="w-4 h-4" />
{notifications.filter(n => !n.is_read).length > 0 && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h4 className="font-semibold">Notifications</h4>
{notifications.filter(n => !n.is_read).length > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
Mark all as read
</Button>
)}
</div>
<ScrollArea className="h-80">
{notifications.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
) : (
<div className="flex flex-col">
{notifications.map((notif: any) => (
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'}`}>
<div className="flex items-center gap-2 mb-1">
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
{notif.level}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{new Date(notif.timestamp).toLocaleString()}
</span>
</div>
<p className="font-medium">{notif.message}</p>
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{/* Theme Toggle */}
<Button
variant="outline"
@@ -924,11 +1033,23 @@ export default function EditorLayout() {
/>
)}
{/* Maintenance Modal */}
<MaintenanceModal
isOpen={maintenanceModalOpen}
onClose={() => setMaintenanceModalOpen(false)}
/>
{/* Notification Settings Modal */}
<NotificationSettingsModal
isOpen={settingsModalOpen}
onClose={() => setSettingsModalOpen(false)}
/>
{/* Stack Alert Sheet */}
<StackAlertSheet
isOpen={alertSheetOpen}
onClose={() => setAlertSheetOpen(false)}
stackName={alertSheetStack}
/>
</div>
);
}
@@ -0,0 +1,267 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { toast } from 'sonner';
interface Agent {
type: 'discord' | 'slack' | 'webhook';
url: string;
enabled: boolean;
}
interface NotificationSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function NotificationSettingsModal({ isOpen, onClose }: NotificationSettingsModalProps) {
const [agents, setAgents] = useState<Record<string, Agent>>({
discord: { type: 'discord', url: '', enabled: false },
slack: { type: 'slack', url: '', enabled: false },
webhook: { type: 'webhook', url: '', enabled: false },
});
const [settings, setSettings] = useState<Record<string, string>>({
host_cpu_limit: '90',
host_ram_limit: '90',
host_disk_limit: '90',
global_crash: '1',
docker_janitor_gb: '5'
});
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (isOpen) {
fetchAgents();
fetchSettings();
}
}, [isOpen]);
const fetchAgents = async () => {
try {
const res = await fetch('/api/agents');
if (res.ok) {
const data: Agent[] = await res.json();
const newAgents = { ...agents };
data.forEach(a => {
newAgents[a.type] = a;
});
setAgents(newAgents);
}
} catch (e) {
console.error('Failed to fetch agents', e);
}
};
const fetchSettings = async () => {
try {
const res = await fetch('/api/settings');
if (res.ok) {
const data = await res.json();
setSettings(prev => ({ ...prev, ...data }));
}
} catch (e) {
console.error('Failed to fetch settings', e);
}
};
const handleAgentChange = (type: string, field: keyof Agent, value: any) => {
setAgents(prev => ({
...prev,
[type]: { ...prev[type], [field]: value }
}));
};
const handleSettingChange = (key: string, value: string) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const saveAgent = async (type: string) => {
setIsLoading(true);
try {
const res = await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(agents[type])
});
if (res.ok) {
toast.success(`${type} settings saved successfully.`);
} else {
toast.error(`Failed to save ${type} settings.`);
}
} catch (e) {
toast.error('Network error.');
} finally {
setIsLoading(false);
}
};
const testAgent = async (type: string) => {
if (!agents[type].url) {
toast.error('Please enter a webhook URL first.');
return;
}
setIsLoading(true);
try {
const res = await fetch('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, url: agents[type].url })
});
if (res.ok) {
toast.success('Test notification sent!');
} else {
const err = await res.json();
toast.error(err.details || 'Test failed.');
}
} catch (e) {
toast.error('Network error.');
} finally {
setIsLoading(false);
}
};
const saveSettings = async () => {
setIsLoading(true);
try {
for (const [key, value] of Object.entries(settings)) {
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
});
}
toast.success('Global events settings saved.');
} catch (e) {
toast.error('Failed to save settings.');
} finally {
setIsLoading(false);
}
};
const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label htmlFor={`${type}-enabled`} className="font-semibold">Enable {title} Notifications</Label>
<Switch
id={`${type}-enabled`}
checked={agents[type].enabled}
onCheckedChange={(c) => handleAgentChange(type, 'enabled', c)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-url`}>Webhook URL</Label>
<Input
id={`${type}-url`}
placeholder="https://..."
value={agents[type].url}
onChange={(e) => handleAgentChange(type, 'url', e.target.value)}
/>
</div>
<div className="flex space-x-2 justify-end pt-4">
<Button variant="outline" onClick={() => testAgent(type)} disabled={isLoading}>Test</Button>
<Button onClick={() => saveAgent(type)} disabled={isLoading}>Save</Button>
</div>
</div>
);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Notification & Alert Settings</DialogTitle>
<DialogDescription>
Configure where alerts are sent and global system limits.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="global" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="global">Global</TabsTrigger>
<TabsTrigger value="discord">Discord</TabsTrigger>
<TabsTrigger value="slack">Slack</TabsTrigger>
<TabsTrigger value="webhook">Webhook</TabsTrigger>
</TabsList>
<TabsContent value="global">
<div className="space-y-6 py-4">
<div className="space-y-4">
<div className="flex justify-between">
<Label>Host CPU Limit (%)</Label>
<span className="text-sm text-muted-foreground">{settings.host_cpu_limit}%</span>
</div>
<Slider
max={100} step={1}
value={[parseInt(settings.host_cpu_limit || '90')]}
onValueChange={(v) => handleSettingChange('host_cpu_limit', v[0].toString())}
/>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label>Host RAM Limit (%)</Label>
<span className="text-sm text-muted-foreground">{settings.host_ram_limit}%</span>
</div>
<Slider
max={100} step={1}
value={[parseInt(settings.host_ram_limit || '90')]}
onValueChange={(v) => handleSettingChange('host_ram_limit', v[0].toString())}
/>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label>Host Disk Limit (%)</Label>
<span className="text-sm text-muted-foreground">{settings.host_disk_limit}%</span>
</div>
<Slider
max={100} step={1}
value={[parseInt(settings.host_disk_limit || '90')]}
onValueChange={(v) => handleSettingChange('host_disk_limit', v[0].toString())}
/>
</div>
<div className="space-y-2">
<Label>Docker Janitor Alert Threshold (GB)</Label>
<Input
type="number"
value={settings.docker_janitor_gb}
onChange={(e) => handleSettingChange('docker_janitor_gb', e.target.value)}
/>
</div>
<div className="flex items-center justify-between pt-2">
<Label htmlFor="global_crash" className="font-semibold">Global Crash Detection (Any Container)</Label>
<Switch
id="global_crash"
checked={settings.global_crash === '1'}
onCheckedChange={(c) => handleSettingChange('global_crash', c ? '1' : '0')}
/>
</div>
<div className="flex justify-end pt-4">
<Button onClick={saveSettings} disabled={isLoading}>Save Global Settings</Button>
</div>
</div>
</TabsContent>
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
+248
View File
@@ -0,0 +1,248 @@
import { useState, useEffect } from 'react';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner';
interface StackAlert {
id?: number;
stack_name: string;
metric: string;
operator: string;
threshold: number;
duration_mins: number;
cooldown_mins: number;
}
interface StackAlertSheetProps {
isOpen: boolean;
onClose: () => void;
stackName: string;
}
export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) {
const [alerts, setAlerts] = useState<StackAlert[]>([]);
const [isLoading, setIsLoading] = useState(false);
// New Alert Form State
const [metric, setMetric] = useState('cpu_percent');
const [operator, setOperator] = useState('>');
const [threshold, setThreshold] = useState('');
const [duration, setDuration] = useState('5');
const [cooldown, setCooldown] = useState('60');
useEffect(() => {
if (isOpen && stackName) {
fetchAlerts();
}
}, [isOpen, stackName]);
const fetchAlerts = async () => {
try {
const res = await fetch(`/api/alerts?stackName=${stackName}`);
if (res.ok) {
const data = await res.json();
setAlerts(data);
}
} catch (e) {
console.error('Failed to fetch alerts', e);
}
};
const addAlert = async () => {
if (!threshold) {
toast.error('Please enter a threshold.');
return;
}
setIsLoading(true);
const newAlert = {
stack_name: stackName,
metric,
operator,
threshold: parseFloat(threshold),
duration_mins: parseInt(duration, 10),
cooldown_mins: parseInt(cooldown, 10)
};
try {
const res = await fetch('/api/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAlert)
});
if (res.ok) {
toast.success('Alert rule added.');
setThreshold('');
fetchAlerts();
} else {
toast.error('Failed to add alert rule.');
}
} catch (e) {
toast.error('Network error.');
} finally {
setIsLoading(false);
}
};
const deleteAlert = async (id: number) => {
setIsLoading(true);
try {
const res = await fetch(`/api/alerts/${id}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Alert rule deleted.');
fetchAlerts();
} else {
toast.error('Failed to delete alert rule.');
}
} catch (e) {
toast.error('Network error.');
} finally {
setIsLoading(false);
}
};
const metricLabels: Record<string, string> = {
cpu_percent: 'CPU Usage (%)',
memory_percent: 'Memory Usage (%)',
memory_mb: 'Memory Usage (MB)',
net_rx: 'Network In (MB)',
net_tx: 'Network Out (MB)',
restart_count: 'Restart Count'
};
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="overflow-y-auto sm:max-w-[400px]">
<SheetHeader>
<SheetTitle>Stack Alerts: {stackName}</SheetTitle>
<SheetDescription>
Configure metric thresholds to trigger notifications for this stack.
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-6">
{/* List Existing Alerts */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Existing Rules</h4>
{alerts.length === 0 ? (
<div className="text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg text-center">
No active alert rules for this stack.
</div>
) : (
alerts.map(alert => (
<div key={alert.id} className="flex flex-col gap-2 p-3 bg-muted/50 rounded-lg border text-sm">
<div className="flex justify-between items-start">
<div>
<span className="font-semibold text-foreground">
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
</span>
<div className="text-muted-foreground mt-1">
Trigger after {alert.duration_mins}m Cooldown: {alert.cooldown_mins}m
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => alert.id && deleteAlert(alert.id)}
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
<hr />
{/* Add New Alert Form */}
<div className="space-y-4">
<h4 className="text-sm font-semibold">Add New Rule</h4>
<div className="space-y-2">
<Label>Metric</Label>
<Select value={metric} onValueChange={setMetric}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(metricLabels).map(([val, label]) => (
<SelectItem key={val} value={val}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Operator</Label>
<Select value={operator} onValueChange={setOperator}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value=">">Greater than</SelectItem>
<SelectItem value=">=">Greater or eq</SelectItem>
<SelectItem value="<">Less than</SelectItem>
<SelectItem value="<=">Less or eq</SelectItem>
<SelectItem value="==">Equals</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Threshold</Label>
<Input
type="number"
value={threshold}
onChange={e => setThreshold(e.target.value)}
placeholder="e.g. 90"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Duration (mins)</Label>
<Input
type="number"
value={duration}
onChange={e => setDuration(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Cooldown (mins)</Label>
<Input
type="number"
value={cooldown}
onChange={e => setCooldown(e.target.value)}
/>
</div>
</div>
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
Add Rule
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,199 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+157
View File
@@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+140
View File
@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
+29
View File
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"