Rework layout, add search/filtering, replace category sections with tags, improve light/dark mode toggle

This commit is contained in:
2025-02-12 12:24:29 -05:00
parent 9852b98904
commit 841cfbb310
11 changed files with 744 additions and 239 deletions

65
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
@@ -1157,6 +1158,12 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/primitive": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
@@ -1581,6 +1588,49 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz",
"integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.5",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.2",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.2",
"@radix-ui/react-portal": "1.1.4",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
@@ -1722,6 +1772,21 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz",
"integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": { "node_modules/@radix-ui/react-use-rect": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz",

View File

@@ -13,6 +13,7 @@
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",

View File

@@ -1,70 +1,46 @@
sections: tags:
- id: "system" system: "System"
title: "System" monitoring: "Monitoring"
icon: "Server" tools: "Tools"
cardStyle: documents: "Documents"
background: "bg-blue-50 dark:bg-blue-950/30" acot: "ACOT"
iconColor: "text-blue-600 dark:text-blue-400" home: "Home"
media: "Media"
- id: "media" # Infrastructure tags
title: "Media" docker: "Docker"
icon: "Play" nas: "NAS"
cardStyle: security: "Security"
background: "bg-orange-50 dark:bg-orange-950/30" automation: "Automation"
iconColor: "text-orange-600 dark:text-orange-400" network: "Network"
# Feature tags
- id: "monitoring" analytics: "Analytics"
title: "Monitoring" backup: "Backup"
icon: "Activity" ai: "AI"
cardStyle: notifications: "Notifications"
background: "bg-emerald-50 dark:bg-emerald-950/30" development: "Development"
iconColor: "text-emerald-600 dark:text-emerald-400" management: "Management"
storage: "Storage"
- id: "tools" documentation: "Documentation"
title: "Tools" printing: "3D Printing"
icon: "Settings" camera: "Camera"
cardStyle: streaming: "Streaming"
background: "bg-purple-50 dark:bg-purple-950/30" download: "Download"
iconColor: "text-purple-600 dark:text-purple-400"
- id: "acot"
title: "ACOT"
icon: "LayoutDashboard"
cardStyle:
background: "bg-sky-50 dark:bg-sky-950/30"
iconColor: "text-sky-600 dark:text-sky-400"
- id: "home"
title: "Home"
icon: "Home"
cardStyle:
background: "bg-indigo-50 dark:bg-indigo-950/30"
iconColor: "text-indigo-600 dark:text-indigo-400"
services: services:
###### System
- name: "Portainer" - name: "Portainer"
description: "Docker Manager" description: "Docker Manager"
url: "https://portainer.kent.pw" url: "https://portainer.kent.pw"
iconName: "portainer" iconName: "portainer"
category: "system" category: "system"
cardStyle: tags: ["system", "docker", "management"]
background: "bg-blue-50 dark:bg-blue-950/30"
iconColor: "text-blue-600 dark:text-blue-400"
- name: "Gitea"
description: "Git Server"
url: "https://gitea.kent.pw"
iconName: "gitea"
category: "system"
cardStyle:
background: "bg-green-50 dark:bg-green-950/30"
iconColor: "text-green-600 dark:text-green-400"
- name: "Cockpit" - name: "Cockpit"
description: "Server Management" description: "Server Management"
url: "https://cockpit.kent.pw" url: "https://cockpit.kent.pw"
iconName: "cockpit" iconName: "cockpit"
category: "system" category: "system"
tags: ["system", "management", "analytics"]
- name: "DiskStation" - name: "DiskStation"
description: "Synology NAS" description: "Synology NAS"
@@ -72,52 +48,7 @@ services:
iconName: "synology" iconName: "synology"
category: "system" category: "system"
monitorName: "Synology Diskstation" monitorName: "Synology Diskstation"
cardStyle: tags: ["system", "nas", "storage", "backup"]
background: "bg-slate-50 dark:bg-slate-950/30"
iconColor: "text-slate-600 dark:text-slate-400"
- name: "Plex"
description: "Media Server"
url: "https://plex.kent.pw"
iconName: "plex"
category: "media"
cardStyle:
background: "bg-orange-50 dark:bg-orange-950/30"
iconColor: "text-orange-600 dark:text-orange-400"
- name: "Sonarr"
description: "TV Manager"
url: "https://sonarr.kent.pw"
iconName: "sonarr"
category: "media"
cardStyle:
background: "bg-blue-50 dark:bg-blue-950/30"
iconColor: "text-blue-600 dark:text-blue-400"
- name: "Jackett"
description: "Torrent Indexer"
url: "https://jackett.kent.pw"
iconName: "jackett"
category: "media"
- name: "Deluge"
description: "Torrent Client"
url: "https://deluge.kent.pw"
iconName: "deluge"
category: "media"
cardStyle:
background: "bg-green-50 dark:bg-green-950/30"
iconColor: "text-green-600 dark:text-green-400"
- name: "Uptime"
description: "Service Monitoring"
url: "https://uptime.kent.pw"
iconName: "uptime-kuma"
category: "monitoring"
monitorName: "Uptime Kuma"
cardStyle:
background: "bg-emerald-50 dark:bg-emerald-950/30"
iconColor: "text-emerald-600 dark:text-emerald-400"
- name: "AdGuard" - name: "AdGuard"
description: "DNS Ad Blocking" description: "DNS Ad Blocking"
@@ -125,32 +56,30 @@ services:
iconName: "adguard-home" iconName: "adguard-home"
category: "system" category: "system"
monitorName: "Adguard Home" monitorName: "Adguard Home"
cardStyle: tags: ["system", "network", "security"]
background: "bg-green-50 dark:bg-green-950/30"
iconColor: "text-green-600 dark:text-green-400"
- name: "NocoDB" - name: "Authelia"
description: "Database Platform" description: "Service Protection"
url: "https://noco.kent.pw" url: "https://auth.kent.pw"
iconName: "nocodb" iconName: "authelia"
category: "tools" category: "system"
monitorName: "NocoDB" tags: ["system", "security", "management"]
- name: "IT Tools" - name: "OliveTin"
description: "Developer Utilities" description: "Run Scripts"
url: "https://ittools.kent.pw" url: "https://olivetin.kent.pw"
iconName: "it-tools" iconName: "olivetin"
category: "tools" category: "system"
monitorName: "IT Tools" tags: ["system", "automation", "management"]
- name: "Firefox" ###### Monitoring
description: "Browser Instance" - name: "Uptime"
url: "https://firefox.kent.pw" description: "Service Monitoring"
iconName: "firefox" url: "https://uptime.kent.pw"
category: "tools" iconName: "uptime-kuma"
cardStyle: category: "monitoring"
background: "bg-orange-50 dark:bg-orange-950/30" monitorName: "Uptime Kuma"
iconColor: "text-orange-600 dark:text-orange-400" tags: ["monitoring", "analytics", "notifications"]
- name: "Speedtest" - name: "Speedtest"
description: "Internet Speed" description: "Internet Speed"
@@ -158,9 +87,7 @@ services:
iconName: "speedtest-tracker" iconName: "speedtest-tracker"
category: "monitoring" category: "monitoring"
monitorName: "Speedtest Tracker" monitorName: "Speedtest Tracker"
cardStyle: tags: ["monitoring", "network", "analytics"]
background: "bg-blue-50 dark:bg-blue-950/30"
iconColor: "text-blue-600 dark:text-blue-400"
- name: "Netdata" - name: "Netdata"
description: "Server Monitoring" description: "Server Monitoring"
@@ -168,9 +95,52 @@ services:
iconName: "netdata" iconName: "netdata"
category: "monitoring" category: "monitoring"
monitorName: "Netdata" monitorName: "Netdata"
cardStyle: tags: ["monitoring", "analytics", "system"]
background: "bg-purple-50 dark:bg-purple-950/30"
iconColor: "text-purple-600 dark:text-purple-400" - name: "Beszel"
description: "Server Monitoring"
url: "https://beszel.kent.pw"
iconName: "beszel"
category: "monitoring"
tags: ["monitoring", "analytics", "system"]
- name: "Dockwatch"
description: "Docker Monitoring"
url: "https://dockwatch.kent.pw"
iconName: "dockwatch"
category: "monitoring"
tags: ["monitoring", "docker", "analytics"]
###### Tools
- name: "Gitea"
description: "Git Server"
url: "https://gitea.kent.pw"
iconName: "gitea"
category: "tools"
tags: ["tools", "development", "management"]
- name: "NocoDB"
description: "Database Platform"
url: "https://noco.kent.pw"
iconName: "nocodb"
category: "tools"
monitorName: "NocoDB"
tags: ["tools", "development", "storage"]
- name: "IT Tools"
description: "Developer Utilities"
url: "https://ittools.kent.pw"
iconName: "it-tools"
category: "tools"
monitorName: "IT Tools"
tags: ["tools", "development"]
- name: "Firefox"
description: "Browser Instance"
url: "https://firefox.kent.pw"
iconName: "firefox"
category: "tools"
tags: ["tools", "automation"]
- name: "FileBrowser" - name: "FileBrowser"
description: "Edit Server Files" description: "Edit Server Files"
@@ -179,48 +149,80 @@ services:
category: "tools" category: "tools"
monitorName: "File Browser" monitorName: "File Browser"
cardStyle: cardStyle:
background: "bg-blue-50 dark:bg-blue-950/30" iconColor: "text-sky-500 dark:text-sky-600"
iconColor: "text-blue-600 dark:text-blue-400" tags: ["tools", "management", "storage"]
- name: "Color Picker"
description: "Convert Colors"
url: "https://kent.pw/color-picker"
iconName: "lucide-rainbow"
category: "tools"
monitorName: "Color-Picker"
cardStyle:
iconColor: "text-indigo-500 dark:text-indigo-600"
tags: ["tools", "development"]
- name: "Ntfy"
description: "Send Notifications"
url: "https://ntfy.kent.pw"
iconName: "ntfy"
category: "tools"
tags: ["tools", "notifications", "automation"]
###### Documents
- name: "Drive" - name: "Drive"
description: "File Storage" description: "File Storage"
url: "https://drive.kent.pw" url: "https://drive.kent.pw"
iconName: "synology" iconName: "synology"
category: "tools" category: "documents"
monitorName: "Synology Drive" monitorName: "Synology Drive"
tags: ["documents", "storage", "backup"]
- name: "Paperless"
description: "Document Storage"
url: "https://paperless.kent.pw"
iconName: "paperless-ngx"
category: "documents"
tags: ["documents", "storage", "documentation"]
- name: "Paperless-AI"
description: "Enhance Paperless"
url: "https://paperless-ai.kent.pw"
iconName: "lucide-leaf"
category: "documents"
cardStyle:
iconColor: "text-blue-500 dark:text-blue-600"
tags: ["documents", "ai", "automation"]
###### ACOT
- name: "Dashboard" - name: "Dashboard"
description: "ACOT Dashboard" description: "ACOT Dashboard"
url: "https://dashboard.kent.pw" url: "https://dashboard.kent.pw"
iconName: "lucide-layout-dashboard" iconName: "lucide-layout-dashboard"
category: "acot" category: "acot"
cardStyle: tags: ["acot", "analytics", "management"]
background: "bg-sky-50 dark:bg-sky-950/30"
iconColor: "text-sky-600 dark:text-sky-400"
- name: "Inventory" - name: "Inventory"
description: "ACOT Inventory" description: "ACOT Inventory"
url: "https://inventory.kent.pw" url: "https://inventory.kent.pw"
iconName: "lucide-box" iconName: "lucide-box"
category: "acot" category: "acot"
tags: ["acot", "management", "storage"]
###### Home
- name: "Homebridge" - name: "Homebridge"
description: "HomeKit Bridge" description: "HomeKit Bridge"
url: "https://homebridge.kent.pw" url: "https://homebridge.kent.pw"
iconName: "homebridge" iconName: "homebridge"
category: "home" category: "home"
cardStyle: tags: ["home", "automation", "management"]
background: "bg-purple-50 dark:bg-purple-950/30"
iconColor: "text-purple-600 dark:text-purple-400"
- name: "Scrypted" - name: "Scrypted"
description: "Manage Cameras" description: "Manage Cameras"
url: "https://scrypted.kent.pw" url: "https://scrypted.kent.pw"
iconName: "scrypted" iconName: "scrypted"
category: "home" category: "home"
cardStyle: tags: ["home", "camera", "streaming"]
background: "bg-indigo-50 dark:bg-indigo-950/30"
iconColor: "text-indigo-600 dark:text-indigo-400"
- name: "3D Printer" - name: "3D Printer"
description: "Fluidd Interface" description: "Fluidd Interface"
@@ -228,9 +230,14 @@ services:
iconName: "fluidd" iconName: "fluidd"
category: "home" category: "home"
monitorName: "Printer" monitorName: "Printer"
cardStyle: tags: ["home", "printing", "automation"]
background: "bg-sky-50 dark:bg-sky-950/30"
iconColor: "text-sky-600 dark:text-sky-400" - name: "Spoolman"
description: "Manage Filament"
url: "https://spoolman.kent.pw"
iconName: "spoolman"
category: "home"
tags: ["home", "printing", "management"]
- name: "Go2RTC" - name: "Go2RTC"
description: "Stream Cameras" description: "Stream Cameras"
@@ -238,6 +245,33 @@ services:
iconName: "lucide-webcam" iconName: "lucide-webcam"
category: "home" category: "home"
monitorName: "Go2RTC" monitorName: "Go2RTC"
cardStyle: tags: ["home", "camera", "streaming"]
background: "bg-orange-50 dark:bg-orange-950/30"
iconColor: "text-orange-600 dark:text-orange-400" ###### Media
- name: "Plex"
description: "Media Server"
url: "https://plex.kent.pw"
iconName: "plex"
category: "media"
tags: ["media", "streaming", "management"]
- name: "Sonarr"
description: "TV Manager"
url: "https://sonarr.kent.pw"
iconName: "sonarr"
category: "media"
tags: ["media", "download", "automation"]
- name: "Jackett"
description: "Torrent Indexer"
url: "https://jackett.kent.pw"
iconName: "jackett"
category: "media"
tags: ["media", "download"]
- name: "Deluge"
description: "Torrent Client"
url: "https://deluge.kent.pw"
iconName: "deluge"
category: "media"
tags: ["media", "download"]

View File

@@ -1,5 +1,4 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Moon, Sun, Monitor} from "lucide-react" import { Moon, Sun, Monitor} from "lucide-react"
import * as LucideIcons from "lucide-react" import * as LucideIcons from "lucide-react"
@@ -7,10 +6,14 @@ import { useTheme } from "@/components/theme-provider"
import { useMetrics } from "./hooks/useMetrics" import { useMetrics } from "./hooks/useMetrics"
import { useConfig } from "./hooks/useConfig" import { useConfig } from "./hooks/useConfig"
import { ServiceStatus } from "./types/metrics" import { ServiceStatus } from "./types/metrics"
import { ServiceCard, Section, CardStyle } from "./types/services" import { ServiceCard, CardStyle, Config } from "./types/services"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertCircle } from "lucide-react" import { AlertCircle, Search, ArrowUpDown, X } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { useState, useMemo, useRef, useEffect } from "react"
const getIconUrl = (name: string | undefined, isDark: boolean = false): string => { const getIconUrl = (name: string | undefined, isDark: boolean = false): string => {
if (!name) return '' if (!name) return ''
@@ -21,7 +24,7 @@ const getIconUrl = (name: string | undefined, isDark: boolean = false): string =
return `https://cdn.jsdelivr.net/gh/selfhst/icons/png/${ref}${suffix}.png` return `https://cdn.jsdelivr.net/gh/selfhst/icons/png/${ref}${suffix}.png`
} }
function ServiceIcon({ service, section, isDark }: { service: ServiceCard, section: Section, isDark: boolean }) { function ServiceIcon({ service, isDark }: { service: ServiceCard, isDark: boolean }) {
if (service.icon) { if (service.icon) {
return <div className="h-6 w-6">{service.icon}</div> return <div className="h-6 w-6">{service.icon}</div>
} }
@@ -34,7 +37,6 @@ function ServiceIcon({ service, section, isDark }: { service: ServiceCard, secti
.map(part => part.charAt(0).toUpperCase() + part.slice(1)) .map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('') .join('')
// Cast to any to avoid TypeScript complexity with dynamic imports
const Icon = (LucideIcons as any)[iconName] || LucideIcons.Box const Icon = (LucideIcons as any)[iconName] || LucideIcons.Box
return <Icon className="h-6 w-6" /> return <Icon className="h-6 w-6" />
} }
@@ -54,20 +56,44 @@ function ServiceIcon({ service, section, isDark }: { service: ServiceCard, secti
) )
} }
// Use section's default icon // Use default icon
const Icon = (LucideIcons as any)[section.icon] || LucideIcons.Box return <LucideIcons.Box className="h-6 w-6" />
return <Icon className="h-6 w-6" />
} }
function ThemeToggle() { function ThemeToggle() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
)
// Update system theme when it changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
setSystemTheme(e.matches ? 'dark' : 'light')
// If we're in system mode, trigger a theme update
if (theme === 'system') {
// Force a re-render by setting the theme again
setTheme('system')
}
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [theme, setTheme])
const cycleTheme = () => { const cycleTheme = () => {
if (theme === 'light') setTheme('dark') if (theme === 'system') {
else if (theme === 'dark') setTheme('system') // If in system mode, switch to the opposite of the current system theme
else setTheme('light') setTheme(systemTheme === 'light' ? 'dark' : 'light')
} else {
// If in light/dark mode, go back to system
setTheme('system')
}
} }
const effectiveTheme: 'light' | 'dark' = theme === 'system' ? systemTheme : (theme as 'light' | 'dark')
const displayTheme = theme === 'system' ? 'System' : effectiveTheme.charAt(0).toUpperCase() + effectiveTheme.slice(1)
return ( return (
<Button <Button
variant="outline" variant="outline"
@@ -76,15 +102,15 @@ function ThemeToggle() {
className="relative w-10 h-10" className="relative w-10 h-10"
> >
<Sun className={`h-[1.2rem] w-[1.2rem] transition-all ${ <Sun className={`h-[1.2rem] w-[1.2rem] transition-all ${
theme === 'light' ? 'scale-100 rotate-0' : 'scale-0 rotate-90' theme !== 'system' && effectiveTheme === 'light' ? 'scale-100 rotate-0' : 'scale-0 rotate-90'
}`} /> }`} />
<Moon className={`absolute h-[1.2rem] w-[1.2rem] transition-all ${ <Moon className={`absolute h-[1.2rem] w-[1.2rem] transition-all ${
theme === 'dark' ? 'scale-100 rotate-0' : 'scale-0 rotate-90' theme !== 'system' && effectiveTheme === 'dark' ? 'scale-100 rotate-0' : 'scale-0 rotate-90'
}`} /> }`} />
<Monitor className={`absolute h-[1.2rem] w-[1.2rem] transition-all ${ <Monitor className={`absolute h-[1.2rem] w-[1.2rem] transition-all ${
theme === 'system' ? 'scale-100 rotate-0' : 'scale-0 rotate-90' theme === 'system' ? 'scale-100 rotate-0' : 'scale-0 rotate-90'
}`} /> }`} />
<span className="sr-only">Toggle theme</span> <span className="sr-only">{displayTheme} theme</span>
</Button> </Button>
) )
} }
@@ -131,63 +157,91 @@ function StatusIndicator({ status, responseTime, certDaysRemaining }: {
) )
} }
function ServiceSection({ section, services, metrics }: { type SortOption = 'name' | 'status' | 'response-time' | 'primary-tag'
section: Section,
function ServiceGrid({ services, metrics, config, sortBy }: {
services: ServiceCard[], services: ServiceCard[],
metrics: Record<string, any> metrics: Record<string, any>,
config: Config,
sortBy: SortOption
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
return ( return (
<div className="space-y-4"> <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] auto-rows-max gap-3">
<div className="flex items-center"> {services.map((service) => {
<h2 className="text-2xl font-bold">{section.title}</h2> const serviceMetrics = metrics[service.monitorName || service.name] || {
<Separator className="flex-1 ml-4" /> status: 'pending',
</div> responseTime: 0,
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3"> certDaysRemaining: 0
{services.map((service) => { }
const serviceMetrics = metrics[service.monitorName || service.name] || {
status: 'pending',
responseTime: 0,
certDaysRemaining: 0
}
const style: CardStyle = { const style: CardStyle = {
background: service.cardStyle?.background || section.cardStyle?.background || "bg-gray-50 dark:bg-gray-950/30", background: service.cardStyle?.background || "bg-gray-50 dark:bg-gray-950/30",
iconColor: service.cardStyle?.iconColor || section.cardStyle?.iconColor || "text-gray-600 dark:text-gray-400" iconColor: service.cardStyle?.iconColor || "text-gray-600 dark:text-gray-400"
} }
return ( const primaryTag = service.tags?.[0] || service.category
<a
key={service.name} return (
href={service.url} <a
target="_blank" key={service.name}
rel="noopener noreferrer" href={service.url}
className="transition-transform hover:scale-105" target="_blank"
> rel="noopener noreferrer"
<Card className={`${style.background} hover:${style.background} overflow-hidden`}> className="transition-transform hover:scale-105"
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 md:pb-1"> >
<div className="flex items-center"> <Card className={`${style.background} hover:${style.background} overflow-hidden h-full`}>
<div className={style.iconColor}> <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-0 md:pb-1">
<ServiceIcon service={service} section={section} isDark={isDark} /> <div className="flex items-center">
</div> <div className={style.iconColor}>
<CardTitle className="ml-1.5">{service.name}</CardTitle> <ServiceIcon service={service} isDark={isDark} />
</div> </div>
<StatusIndicator <CardTitle className="ml-1.5">{service.name}</CardTitle>
status={serviceMetrics.status} </div>
responseTime={serviceMetrics.responseTime} <StatusIndicator
certDaysRemaining={serviceMetrics.certDaysRemaining} status={serviceMetrics.status}
/> responseTime={serviceMetrics.responseTime}
</CardHeader> certDaysRemaining={serviceMetrics.certDaysRemaining}
<CardContent className="p-4 pt-1 hidden md:block"> />
<CardDescription className="text-sm text-foreground-muted">{service.description}</CardDescription> </CardHeader>
</CardContent> <CardContent className="p-4 pt-1">
</Card> <CardDescription className="text-sm text-foreground-muted hidden md:block">
</a> {service.description}
) </CardDescription>
})} {(sortBy === 'primary-tag' || sortBy === 'status' || sortBy === 'response-time') && (
</div> <div className="flex flex-wrap gap-1 mt-2">
{sortBy === 'primary-tag' && (
<Badge variant="outline" className="text-xs">
{config.tags?.[primaryTag] || primaryTag}
</Badge>
)}
{sortBy === 'status' && (
<Badge
variant="outline"
className={`text-xs ${
serviceMetrics.status === 'up' ? 'border-green-500 text-green-500' :
serviceMetrics.status === 'down' ? 'border-red-500 text-red-500' :
serviceMetrics.status === 'maintenance' ? 'border-blue-500 text-blue-500' :
'border-yellow-500 text-yellow-500'
}`}
>
{serviceMetrics.status.charAt(0).toUpperCase() + serviceMetrics.status.slice(1)}
</Badge>
)}
{sortBy === 'response-time' && serviceMetrics.responseTime > 0 && (
<Badge variant="outline" className="text-xs">
{serviceMetrics.responseTime}ms
</Badge>
)}
</div>
)}
</CardContent>
</Card>
</a>
)
})}
</div> </div>
) )
} }
@@ -195,7 +249,98 @@ function ServiceSection({ section, services, metrics }: {
function App() { function App() {
const { metrics } = useMetrics() const { metrics } = useMetrics()
const { config, error } = useConfig() const { config, error } = useConfig()
const { services, sections } = config const { services } = config
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<SortOption>('name')
const [selectedTags, setSelectedTags] = useState<string[]>([])
const searchInputRef = useRef<HTMLInputElement>(null)
const { setTheme } = useTheme()
// Force system mode on initial load
useEffect(() => {
setTheme('system')
}, [])
// Focus search input on mount
useEffect(() => {
searchInputRef.current?.focus()
}, [])
// Handle enter key in search
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && filteredAndSortedServices.length > 0) {
const firstService = filteredAndSortedServices[0]
window.open(firstService.url, '_blank', 'noopener,noreferrer')
}
}
// Get unique tags from all services
const availableTags = useMemo(() => {
if (!services) return []
const tags = new Set<string>()
services.forEach(service => {
// Add the category as a tag for backward compatibility
tags.add(service.category)
// Add any additional tags
service.tags?.forEach(tag => tags.add(tag))
})
return Array.from(tags).sort()
}, [services])
const filteredAndSortedServices = useMemo(() => {
if (!services) return []
let filtered = services.filter(service => {
const matchesSearch =
service.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
service.description.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTags = selectedTags.length === 0 || (
selectedTags.some(tag =>
service.category === tag || // Check category for backward compatibility
service.tags?.includes(tag) // Check additional tags
)
)
return matchesSearch && matchesTags
})
return filtered.sort((a, b) => {
const metricsA = metrics[a.monitorName || a.name] || { status: 'pending', responseTime: 0 }
const metricsB = metrics[b.monitorName || b.name] || { status: 'pending', responseTime: 0 }
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name)
case 'status':
// Sort by status: up > maintenance > pending > down
const statusOrder = { up: 0, maintenance: 1, pending: 2, down: 3 }
const statusA = statusOrder[metricsA.status] ?? 4 // Unknown status goes last
const statusB = statusOrder[metricsB.status] ?? 4
return statusA - statusB || metricsA.responseTime - metricsB.responseTime // Use response time as secondary sort
case 'response-time':
// Put services with no response time (0 or undefined) at the end
const rtA = metricsA.responseTime || Number.MAX_SAFE_INTEGER
const rtB = metricsB.responseTime || Number.MAX_SAFE_INTEGER
return rtA - rtB || a.name.localeCompare(b.name) // Use name as secondary sort
case 'primary-tag':
// Get primary tag (first tag or category)
const tagA = a.tags?.[0] || a.category
const tagB = b.tags?.[0] || b.category
return tagA.localeCompare(tagB)
default:
return 0
}
})
}, [services, searchQuery, sortBy, metrics, selectedTags])
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag)
? [] // If clicking the selected tag, clear selection
: [tag] // Otherwise, replace any existing selection with the new tag
)
}
return ( return (
<div className="min-h-screen p-8 space-y-8"> <div className="min-h-screen p-8 space-y-8">
@@ -214,22 +359,65 @@ function App() {
</Alert> </Alert>
)} )}
{sections?.length > 0 ? ( <div className="space-y-4">
<> <div className="flex gap-4 items-center">
{sections.map((section: Section) => { <div className="relative flex-1">
const sectionServices = services.filter((s: { category: string }) => s.category === section.id) <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
if (sectionServices.length === 0) return null <Input
ref={searchInputRef}
return ( placeholder="Search..."
<ServiceSection value={searchQuery}
key={section.id} onChange={(e) => setSearchQuery(e.target.value)}
section={section} onKeyDown={handleSearchKeyDown}
services={sectionServices} className="pl-8 pr-8 bg-gray-50 dark:bg-gray-950/30 max-w-md"
metrics={metrics} />
/> {searchQuery && (
) <button
})} onClick={() => setSearchQuery("")}
</> className="absolute right-0 top-0 text-muted-foreground bg-transparent border-none hover:text-foreground hover:border-none"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="hidden sm:block">
<Select value={sortBy} onValueChange={(value: SortOption) => setSortBy(value)}>
<SelectTrigger className="w-[180px] bg-gray-50 dark:bg-gray-950/30">
<ArrowUpDown className="mr-2 h-4 w-4" />
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="status">Status</SelectItem>
<SelectItem value="response-time">Response Time</SelectItem>
<SelectItem value="primary-tag">Primary Tag</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-2">
{availableTags.map(tag => (
<Badge
key={tag}
variant={selectedTags.includes(tag) ? "default" : "outline"}
onClick={() => toggleTag(tag)}
className="gap-1 cursor-pointer bg-gray-50 dark:bg-gray-950/30"
>
{config.tags?.[tag] || tag}
{selectedTags.includes(tag) && <X className="h-3 w-3" />}
</Badge>
))}
</div>
</div>
{services?.length > 0 ? (
<ServiceGrid
services={filteredAndSortedServices}
metrics={metrics}
config={config}
sortBy={sortBy}
/>
) : !error && ( ) : !error && (
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

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 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-96 min-w-[8rem] overflow-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",
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,
}

View File

@@ -73,7 +73,7 @@ button:focus-visible {
@layer base { @layer base {
:root { :root {
--background: 210 20% 96%; --background: 220 13% 91%;
--foreground: 222.2 84% 4.9%; --foreground: 222.2 84% 4.9%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 222.2 84% 4.9%;
@@ -89,15 +89,15 @@ button:focus-visible {
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%; --border: 216 12.2% 83.9%;
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 215 27.9% 16.9%;
--foreground: 210 40% 98%; --foreground: 220 14.3% 95.9%;
--card: 222.2 84% 4.9%; --card: 220.9 39.3% 11%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%; --popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
@@ -111,7 +111,7 @@ button:focus-visible {
--accent-foreground: 210 40% 98%; --accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%; --border: 215 13.8% 34.1%;
--input: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%; --ring: 212.7 26.8% 83.9%;
} }

View File

@@ -6,7 +6,7 @@ import { ThemeProvider } from './components/theme-provider'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<App /> <App />
</ThemeProvider> </ThemeProvider>
</React.StrictMode>, </React.StrictMode>,

View File

@@ -21,6 +21,7 @@ export interface ServiceCard {
icon?: ReactNode icon?: ReactNode
iconName?: string iconName?: string
category: CategoryId category: CategoryId
tags?: string[]
monitorName?: string monitorName?: string
cardStyle?: CardStyle cardStyle?: CardStyle
} }
@@ -28,4 +29,5 @@ export interface ServiceCard {
export interface Config { export interface Config {
sections: Section[] sections: Section[]
services: ServiceCard[] services: ServiceCard[]
tags?: { [key: string]: string }
} }

View File

@@ -7,14 +7,14 @@ export default {
], ],
safelist: [ safelist: [
{ {
pattern: /^bg-(blue|gray|green|red|yellow|purple|pink|indigo|orange)-(50|100|200|300|400|500|600|700|800|900|950)/, pattern: /^bg-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(50|100|200|300|400|500|600|700|800|900|950)/,
variants: ['hover', 'dark'] variants: ['hover', 'dark']
}, },
{ {
pattern: /^text-(blue|gray|green|red|yellow|purple|pink|indigo|orange)-(50|100|200|300|400|500|600|700|800|900|950)/, pattern: /^text-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(50|100|200|300|400|500|600|700|800|900|950)/,
variants: ['dark'] variants: ['dark']
} }
], ],
theme: { theme: {
container: { container: {
center: true, center: true,