This commit is contained in:
Stijnus
2025-01-22 02:05:18 +01:00
parent a94330e4a4
commit 723c6a4f02
4 changed files with 302 additions and 95 deletions

View File

@@ -4,6 +4,7 @@ import { logStore } from '~/lib/stores/logs';
import { useStore } from '@nanostores/react';
import { formatDistanceToNow } from 'date-fns';
import { classNames } from '~/utils/classNames';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
interface NotificationDetails {
type?: string;
@@ -14,8 +15,10 @@ interface NotificationDetails {
updateUrl?: string;
}
type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network';
const NotificationsTab = () => {
const [filter, setFilter] = useState<'all' | 'error' | 'warning' | 'update'>('all');
const [filter, setFilter] = useState<FilterType>('all');
const logs = useStore(logStore.logs);
const handleClearNotifications = () => {
@@ -29,42 +32,64 @@ const NotificationsTab = () => {
const filteredLogs = Object.values(logs)
.filter((log) => {
if (filter === 'all') {
return log.level === 'error' || log.level === 'warning' || log.details?.type === 'update';
return true;
}
if (filter === 'update') {
return log.details?.type === 'update';
}
if (filter === 'system') {
return log.category === 'system';
}
if (filter === 'provider') {
return log.category === 'provider';
}
if (filter === 'network') {
return log.category === 'network';
}
return log.level === filter;
})
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const getNotificationStyle = (log: (typeof filteredLogs)[0]) => {
if (log.details?.type === 'update') {
const getNotificationStyle = (level: string, type?: string) => {
if (type === 'update') {
return {
border: 'border-purple-200 dark:border-purple-900/50',
bg: 'bg-purple-50 dark:bg-purple-900/20',
icon: 'i-ph:arrow-circle-up text-purple-600 dark:text-purple-400',
text: 'text-purple-900 dark:text-purple-300',
icon: 'i-ph:arrow-circle-up',
color: 'text-purple-500 dark:text-purple-400',
bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
};
}
if (log.level === 'error') {
return {
border: 'border-red-200 dark:border-red-900/50',
bg: 'bg-red-50 dark:bg-red-900/20',
icon: 'i-ph:warning-circle text-red-600 dark:text-red-400',
text: 'text-red-900 dark:text-red-300',
};
switch (level) {
case 'error':
return {
icon: 'i-ph:warning-circle',
color: 'text-red-500 dark:text-red-400',
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
};
case 'warning':
return {
icon: 'i-ph:warning',
color: 'text-yellow-500 dark:text-yellow-400',
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
};
case 'info':
return {
icon: 'i-ph:info',
color: 'text-blue-500 dark:text-blue-400',
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
};
default:
return {
icon: 'i-ph:bell',
color: 'text-gray-500 dark:text-gray-400',
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
};
}
return {
border: 'border-yellow-200 dark:border-yellow-900/50',
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
icon: 'i-ph:warning text-yellow-600 dark:text-yellow-400',
text: 'text-yellow-900 dark:text-yellow-300',
};
};
const renderNotificationDetails = (details: NotificationDetails) => {
@@ -79,7 +104,16 @@ const NotificationsTab = () => {
</div>
<button
onClick={() => details.updateUrl && handleUpdateAction(details.updateUrl)}
className="mt-2 inline-flex items-center gap-2 rounded-md bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-600 hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400 dark:hover:bg-purple-900/30"
className={classNames(
'mt-2 inline-flex items-center gap-2',
'rounded-lg px-3 py-1.5',
'text-sm font-medium',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'text-gray-900 dark:text-white',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'transition-all duration-200',
)}
>
<span className="i-ph:git-branch text-lg" />
View Changes
@@ -91,54 +125,134 @@ const NotificationsTab = () => {
return details.message ? <p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p> : null;
};
const filterOptions: { id: FilterType; label: string; icon: string }[] = [
{ id: 'all', label: 'All Notifications', icon: 'i-ph:bell' },
{ id: 'system', label: 'System', icon: 'i-ph:gear' },
{ id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up' },
{ id: 'error', label: 'Errors', icon: 'i-ph:warning-circle' },
{ id: 'warning', label: 'Warnings', icon: 'i-ph:warning' },
{ id: 'info', label: 'Information', icon: 'i-ph:info' },
{ id: 'provider', label: 'Providers', icon: 'i-ph:robot' },
{ id: 'network', label: 'Network', icon: 'i-ph:wifi-high' },
];
return (
<div className="flex h-full flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'error' | 'warning' | 'update')}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="all">All Notifications</option>
<option value="update">Updates</option>
<option value="error">Errors</option>
<option value="warning">Warnings</option>
</select>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className={classNames(
'flex items-center gap-2',
'rounded-lg px-3 py-1.5',
'text-sm text-gray-900 dark:text-white',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'transition-all duration-200',
)}
>
<span
className={classNames(
filterOptions.find((opt) => opt.id === filter)?.icon || 'i-ph:funnel',
'text-lg text-gray-500 dark:text-gray-400',
)}
/>
{filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95"
sideOffset={5}
align="start"
side="bottom"
>
{filterOptions.map((option) => (
<DropdownMenu.Item
key={option.id}
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
onClick={() => setFilter(option.id)}
>
<div className="mr-3 flex h-5 w-5 items-center justify-center">
<div
className={classNames(
option.icon,
'text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors',
)}
/>
</div>
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
onClick={handleClearNotifications}
className="rounded-md bg-gray-50 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
className={classNames(
'group flex items-center gap-2',
'rounded-lg px-3 py-1.5',
'text-sm text-gray-900 dark:text-white',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
'transition-all duration-200',
)}
>
<span className="i-ph:trash text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
Clear All
</button>
</div>
<div className="flex flex-col gap-4">
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-gray-200 p-8 text-center dark:border-gray-700">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={classNames(
'flex flex-col items-center justify-center gap-4',
'rounded-lg p-8 text-center',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
)}
>
<span className="i-ph:bell-slash text-4xl text-gray-400 dark:text-gray-600" />
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">No Notifications</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Notifications</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">You're all caught up!</p>
</div>
</div>
</motion.div>
) : (
filteredLogs.map((log) => {
const style = getNotificationStyle(log);
const style = getNotificationStyle(log.level, log.details?.type);
return (
<motion.div
key={log.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={classNames('flex flex-col gap-2 rounded-lg border p-4', style.border, style.bg)}
className={classNames(
'flex flex-col gap-2',
'rounded-lg p-4',
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
style.bg,
'transition-all duration-200',
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<span className={classNames('text-lg', style.icon)} />
<div>
<h3 className={classNames('text-sm font-medium', style.text)}>{log.message}</h3>
<div className="flex items-start gap-3">
<span className={classNames('text-lg', style.icon, style.color)} />
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</h3>
{log.details && renderNotificationDetails(log.details as NotificationDetails)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Category: {log.category}
{log.subCategory ? ` > ${log.subCategory}` : ''}
</p>
</div>
</div>
<time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">