mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
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>
188 lines
6.0 KiB
TypeScript
188 lines
6.0 KiB
TypeScript
import {
|
|
IconBolt,
|
|
IconHelp,
|
|
IconMapRoute,
|
|
IconPlus,
|
|
IconSettings,
|
|
IconWifiOff,
|
|
} from '@tabler/icons-react'
|
|
import { Head, usePage } from '@inertiajs/react'
|
|
import AppLayout from '~/layouts/AppLayout'
|
|
import { getServiceLink } from '~/lib/navigation'
|
|
import { ServiceSlim } from '../../types/services'
|
|
import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
|
|
import { useUpdateAvailable } from '~/hooks/useUpdateAvailable'
|
|
import { useSystemSetting } from '~/hooks/useSystemSetting'
|
|
import Alert from '~/components/Alert'
|
|
import { SERVICE_NAMES } from '../../constants/service_names'
|
|
|
|
// Maps is a Core Capability (display_order: 4)
|
|
const MAPS_ITEM = {
|
|
label: 'Maps',
|
|
to: '/maps',
|
|
target: '',
|
|
description: 'View offline maps',
|
|
icon: <IconMapRoute size={48} />,
|
|
installed: true,
|
|
displayOrder: 4,
|
|
poweredBy: null,
|
|
}
|
|
|
|
// System items shown after all apps
|
|
const SYSTEM_ITEMS = [
|
|
{
|
|
label: 'Easy Setup',
|
|
to: '/easy-setup',
|
|
target: '',
|
|
description:
|
|
'Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!',
|
|
icon: <IconBolt size={48} />,
|
|
installed: true,
|
|
displayOrder: 50,
|
|
poweredBy: null,
|
|
},
|
|
{
|
|
label: 'Install Apps',
|
|
to: '/settings/apps',
|
|
target: '',
|
|
description: 'Not seeing your favorite app? Install it here!',
|
|
icon: <IconPlus size={48} />,
|
|
installed: true,
|
|
displayOrder: 51,
|
|
poweredBy: null,
|
|
},
|
|
{
|
|
label: 'Docs',
|
|
to: '/docs/home',
|
|
target: '',
|
|
description: 'Read Project N.O.M.A.D. manuals and guides',
|
|
icon: <IconHelp size={48} />,
|
|
installed: true,
|
|
displayOrder: 52,
|
|
poweredBy: null,
|
|
},
|
|
{
|
|
label: 'Settings',
|
|
to: '/settings/system',
|
|
target: '',
|
|
description: 'Configure your N.O.M.A.D. settings',
|
|
icon: <IconSettings size={48} />,
|
|
installed: true,
|
|
displayOrder: 53,
|
|
poweredBy: null,
|
|
},
|
|
]
|
|
|
|
interface DashboardItem {
|
|
label: string
|
|
to: string
|
|
target: string
|
|
description: string
|
|
icon: React.ReactNode
|
|
installed: boolean
|
|
displayOrder: number
|
|
poweredBy: string | null
|
|
}
|
|
|
|
export default function Home(props: {
|
|
system: {
|
|
services: ServiceSlim[]
|
|
}
|
|
}) {
|
|
const items: DashboardItem[] = []
|
|
const updateInfo = useUpdateAvailable();
|
|
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
|
|
|
|
// Check if user has visited Easy Setup
|
|
const { data: easySetupVisited } = useSystemSetting({
|
|
key: 'ui.hasVisitedEasySetup'
|
|
})
|
|
const shouldHighlightEasySetup = easySetupVisited?.value ? easySetupVisited?.value !== 'true' : false
|
|
|
|
// Add installed services (non-dependency services only)
|
|
props.system.services
|
|
.filter((service) => service.installed && service.ui_location)
|
|
.forEach((service) => {
|
|
items.push({
|
|
// Inject custom AI Assistant name if this is the chat service
|
|
label: service.service_name === SERVICE_NAMES.OLLAMA && aiAssistantName ? aiAssistantName : (service.friendly_name || service.service_name),
|
|
to: service.ui_location ? getServiceLink(service.ui_location) : '#',
|
|
target: '_blank',
|
|
description:
|
|
service.description ||
|
|
`Access the ${service.friendly_name || service.service_name} application`,
|
|
icon: service.icon ? (
|
|
<DynamicIcon icon={service.icon as DynamicIconName} className="!size-12" />
|
|
) : (
|
|
<IconWifiOff size={48} />
|
|
),
|
|
installed: service.installed,
|
|
displayOrder: service.display_order ?? 100,
|
|
poweredBy: service.powered_by ?? null,
|
|
})
|
|
})
|
|
|
|
// Add Maps as a Core Capability
|
|
items.push(MAPS_ITEM)
|
|
|
|
// Add system items
|
|
items.push(...SYSTEM_ITEMS)
|
|
|
|
// Sort all items by display order
|
|
items.sort((a, b) => a.displayOrder - b.displayOrder)
|
|
|
|
return (
|
|
<AppLayout>
|
|
<Head title="Command Center" />
|
|
{
|
|
updateInfo?.updateAvailable && (
|
|
<div className='flex justify-center items-center p-4 w-full'>
|
|
<Alert
|
|
title="An update is available for Project N.O.M.A.D.!"
|
|
type="info-inverted"
|
|
variant="solid"
|
|
className="w-full"
|
|
buttonProps={{
|
|
variant: 'primary',
|
|
children: 'Go to Settings',
|
|
icon: 'IconSettings',
|
|
onClick: () => {
|
|
window.location.href = '/settings/update'
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
|
{items.map((item) => {
|
|
const isEasySetup = item.label === 'Easy Setup'
|
|
const shouldHighlight = isEasySetup && shouldHighlightEasySetup
|
|
|
|
return (
|
|
<a key={item.label} href={item.to} target={item.target}>
|
|
<div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4">
|
|
{shouldHighlight && (
|
|
<span className="absolute top-2 right-2 flex items-center justify-center">
|
|
<span
|
|
className="animate-ping absolute inline-flex w-16 h-6 rounded-full bg-desert-orange-light opacity-75"
|
|
style={{ animationDuration: '1.5s' }}
|
|
></span>
|
|
<span className="relative inline-flex items-center rounded-full px-2.5 py-1 bg-desert-orange-light text-xs font-semibold text-white shadow-sm">
|
|
Start here!
|
|
</span>
|
|
</span>
|
|
)}
|
|
<div className="flex items-center justify-center mb-2">{item.icon}</div>
|
|
<h3 className="font-bold text-2xl">{item.label}</h3>
|
|
{item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>}
|
|
<p className="xl:text-lg mt-2">{item.description}</p>
|
|
</div>
|
|
</a>
|
|
)
|
|
})}
|
|
</div>
|
|
</AppLayout>
|
|
)
|
|
}
|