project-nomad/admin/inertia/providers/NotificationProvider.tsx
Chris Sherwood b1edef27e8 feat(UI): add Night Ops dark mode with theme toggle
Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping
under [data-theme="dark"]. All 23 desert palette variables are overridden
with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white,
text-gray-*, border-gray-*) are replaced with semantic tokens.

Infrastructure:
- CSS variable overrides in app.css for both themes
- ThemeProvider + useTheme hook (localStorage + KV store sync)
- ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels)
- FOUC prevention script in inertia_layout.edge
- Toggle placed in StyledSidebar and Footer for access on every page

Color replacements across 50 files:
- bg-white → bg-surface-primary
- bg-gray-50/100 → bg-surface-secondary
- text-gray-900/800 → text-text-primary
- text-gray-600/500 → text-text-secondary/text-text-muted
- border-gray-200/300 → border-border-subtle/border-border-default
- text-desert-white → text-white (fixes invisible text on colored bg)
- Button hover/active states use dedicated btn-green-hover/active vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00

83 lines
2.6 KiB
TypeScript

import { useState, useEffect } from 'react'
import { NotificationContext, Notification } from '../context/NotificationContext'
import { IconExclamationCircle, IconCircleCheck, IconInfoCircle } from '@tabler/icons-react'
import { setGlobalNotificationCallback } from '~/lib/util'
const NotificationsProvider = ({ children }: { children: React.ReactNode }) => {
const [notifications, setNotifications] = useState<(Notification & { id: string })[]>([])
const addNotification = (newNotif: Notification) => {
const { message, type, duration = 5000 } = newNotif
const id = crypto.randomUUID()
setNotifications((prev) => [...prev, { id, message, type, duration }])
if (duration > 0) {
setTimeout(() => {
removeNotification(id)
}, duration)
}
}
// Set the global notification callback when provider mounts
useEffect(() => {
setGlobalNotificationCallback(addNotification)
return () => {
setGlobalNotificationCallback(() => {})
}
}, [])
const removeNotification = (id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id))
}
const removeAllNotifications = () => {
setNotifications([])
}
const Icon = ({ type }: { type: string }) => {
switch (type) {
case 'error':
return <IconExclamationCircle className="h-5 w-5 text-red-500" />
case 'success':
return <IconCircleCheck className="h-5 w-5 text-green-500" />
case 'info':
return <IconInfoCircle className="h-5 w-5 text-blue-500" />
default:
return <IconInfoCircle className="h-5 w-5 text-blue-500" />
}
}
return (
<NotificationContext.Provider
value={{
notifications,
addNotification,
removeNotification,
removeAllNotifications,
}}
>
{children}
<div className="!fixed bottom-16 right-0 p-4 z-[9999]">
{notifications.map((notification) => (
<div
key={notification.id}
className={`mb-4 p-4 rounded shadow-md border border-border-default bg-surface-primary max-w-96`}
onClick={() => removeNotification(notification.id)}
>
<div className="flex flex-row items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<Icon type={notification.type} />
</div>
<div className="flex-1 min-w-0">
<p className="break-words">{notification.message}</p>
</div>
</div>
</div>
))}
</div>
</NotificationContext.Provider>
)
}
export default NotificationsProvider