From 841cfbb310259698f15aa85109de7ea777e96599 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 12 Feb 2025 12:24:29 -0500 Subject: [PATCH] Rework layout, add search/filtering, replace category sections with tags, improve light/dark mode toggle --- package-lock.json | 65 +++++++ package.json | 1 + public/config.yaml | 330 ++++++++++++++++++--------------- src/App.tsx | 346 +++++++++++++++++++++++++++-------- src/components/ui/badge.tsx | 36 ++++ src/components/ui/input.tsx | 22 +++ src/components/ui/select.tsx | 157 ++++++++++++++++ src/index.css | 12 +- src/main.tsx | 2 +- src/types/services.ts | 2 + tailwind.config.js | 10 +- 11 files changed, 744 insertions(+), 239 deletions(-) create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/select.tsx diff --git a/package-lock.json b/package-lock.json index 960ea1d..a481422 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.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-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", @@ -1157,6 +1158,12 @@ "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": { "version": "1.1.1", "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": { "version": "1.1.2", "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", diff --git a/package.json b/package.json index c5586a9..5341aea 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.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-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", diff --git a/public/config.yaml b/public/config.yaml index 88e5c1f..63ad281 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -1,70 +1,46 @@ -sections: - - id: "system" - title: "System" - icon: "Server" - cardStyle: - background: "bg-blue-50 dark:bg-blue-950/30" - iconColor: "text-blue-600 dark:text-blue-400" - - - id: "media" - title: "Media" - icon: "Play" - cardStyle: - background: "bg-orange-50 dark:bg-orange-950/30" - iconColor: "text-orange-600 dark:text-orange-400" - - - id: "monitoring" - title: "Monitoring" - icon: "Activity" - cardStyle: - background: "bg-emerald-50 dark:bg-emerald-950/30" - iconColor: "text-emerald-600 dark:text-emerald-400" - - - id: "tools" - title: "Tools" - icon: "Settings" - cardStyle: - background: "bg-purple-50 dark:bg-purple-950/30" - 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" +tags: + system: "System" + monitoring: "Monitoring" + tools: "Tools" + documents: "Documents" + acot: "ACOT" + home: "Home" + media: "Media" + # Infrastructure tags + docker: "Docker" + nas: "NAS" + security: "Security" + automation: "Automation" + network: "Network" + # Feature tags + analytics: "Analytics" + backup: "Backup" + ai: "AI" + notifications: "Notifications" + development: "Development" + management: "Management" + storage: "Storage" + documentation: "Documentation" + printing: "3D Printing" + camera: "Camera" + streaming: "Streaming" + download: "Download" services: +###### System - name: "Portainer" description: "Docker Manager" url: "https://portainer.kent.pw" iconName: "portainer" category: "system" - cardStyle: - 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" + tags: ["system", "docker", "management"] - name: "Cockpit" description: "Server Management" url: "https://cockpit.kent.pw" iconName: "cockpit" category: "system" + tags: ["system", "management", "analytics"] - name: "DiskStation" description: "Synology NAS" @@ -72,52 +48,7 @@ services: iconName: "synology" category: "system" monitorName: "Synology Diskstation" - cardStyle: - 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" + tags: ["system", "nas", "storage", "backup"] - name: "AdGuard" description: "DNS Ad Blocking" @@ -125,32 +56,30 @@ services: iconName: "adguard-home" category: "system" monitorName: "Adguard Home" - cardStyle: - background: "bg-green-50 dark:bg-green-950/30" - iconColor: "text-green-600 dark:text-green-400" + tags: ["system", "network", "security"] - - name: "NocoDB" - description: "Database Platform" - url: "https://noco.kent.pw" - iconName: "nocodb" - category: "tools" - monitorName: "NocoDB" + - name: "Authelia" + description: "Service Protection" + url: "https://auth.kent.pw" + iconName: "authelia" + category: "system" + tags: ["system", "security", "management"] - - name: "IT Tools" - description: "Developer Utilities" - url: "https://ittools.kent.pw" - iconName: "it-tools" - category: "tools" - monitorName: "IT Tools" - - - name: "Firefox" - description: "Browser Instance" - url: "https://firefox.kent.pw" - iconName: "firefox" - category: "tools" - cardStyle: - background: "bg-orange-50 dark:bg-orange-950/30" - iconColor: "text-orange-600 dark:text-orange-400" + - name: "OliveTin" + description: "Run Scripts" + url: "https://olivetin.kent.pw" + iconName: "olivetin" + category: "system" + tags: ["system", "automation", "management"] + +###### Monitoring + - name: "Uptime" + description: "Service Monitoring" + url: "https://uptime.kent.pw" + iconName: "uptime-kuma" + category: "monitoring" + monitorName: "Uptime Kuma" + tags: ["monitoring", "analytics", "notifications"] - name: "Speedtest" description: "Internet Speed" @@ -158,9 +87,7 @@ services: iconName: "speedtest-tracker" category: "monitoring" monitorName: "Speedtest Tracker" - cardStyle: - background: "bg-blue-50 dark:bg-blue-950/30" - iconColor: "text-blue-600 dark:text-blue-400" + tags: ["monitoring", "network", "analytics"] - name: "Netdata" description: "Server Monitoring" @@ -168,9 +95,52 @@ services: iconName: "netdata" category: "monitoring" monitorName: "Netdata" - cardStyle: - background: "bg-purple-50 dark:bg-purple-950/30" - iconColor: "text-purple-600 dark:text-purple-400" + tags: ["monitoring", "analytics", "system"] + + - 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" description: "Edit Server Files" @@ -179,48 +149,80 @@ services: category: "tools" monitorName: "File Browser" cardStyle: - background: "bg-blue-50 dark:bg-blue-950/30" - iconColor: "text-blue-600 dark:text-blue-400" + iconColor: "text-sky-500 dark:text-sky-600" + 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" description: "File Storage" url: "https://drive.kent.pw" iconName: "synology" - category: "tools" + category: "documents" 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" description: "ACOT Dashboard" url: "https://dashboard.kent.pw" iconName: "lucide-layout-dashboard" category: "acot" - cardStyle: - background: "bg-sky-50 dark:bg-sky-950/30" - iconColor: "text-sky-600 dark:text-sky-400" + tags: ["acot", "analytics", "management"] - name: "Inventory" description: "ACOT Inventory" url: "https://inventory.kent.pw" iconName: "lucide-box" category: "acot" + tags: ["acot", "management", "storage"] +###### Home - name: "Homebridge" description: "HomeKit Bridge" url: "https://homebridge.kent.pw" iconName: "homebridge" category: "home" - cardStyle: - background: "bg-purple-50 dark:bg-purple-950/30" - iconColor: "text-purple-600 dark:text-purple-400" + tags: ["home", "automation", "management"] - name: "Scrypted" description: "Manage Cameras" url: "https://scrypted.kent.pw" iconName: "scrypted" category: "home" - cardStyle: - background: "bg-indigo-50 dark:bg-indigo-950/30" - iconColor: "text-indigo-600 dark:text-indigo-400" + tags: ["home", "camera", "streaming"] - name: "3D Printer" description: "Fluidd Interface" @@ -228,9 +230,14 @@ services: iconName: "fluidd" category: "home" monitorName: "Printer" - cardStyle: - background: "bg-sky-50 dark:bg-sky-950/30" - iconColor: "text-sky-600 dark:text-sky-400" + tags: ["home", "printing", "automation"] + + - name: "Spoolman" + description: "Manage Filament" + url: "https://spoolman.kent.pw" + iconName: "spoolman" + category: "home" + tags: ["home", "printing", "management"] - name: "Go2RTC" description: "Stream Cameras" @@ -238,6 +245,33 @@ services: iconName: "lucide-webcam" category: "home" monitorName: "Go2RTC" - cardStyle: - background: "bg-orange-50 dark:bg-orange-950/30" - iconColor: "text-orange-600 dark:text-orange-400" \ No newline at end of file + tags: ["home", "camera", "streaming"] + +###### 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"] \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e61c9ca..36ad70a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Separator } from "@/components/ui/separator" import { Button } from "@/components/ui/button" import { Moon, Sun, Monitor} 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 { useConfig } from "./hooks/useConfig" 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 { 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 => { 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` } -function ServiceIcon({ service, section, isDark }: { service: ServiceCard, section: Section, isDark: boolean }) { +function ServiceIcon({ service, isDark }: { service: ServiceCard, isDark: boolean }) { if (service.icon) { return
{service.icon}
} @@ -34,7 +37,6 @@ function ServiceIcon({ service, section, isDark }: { service: ServiceCard, secti .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join('') - // Cast to any to avoid TypeScript complexity with dynamic imports const Icon = (LucideIcons as any)[iconName] || LucideIcons.Box return } @@ -54,20 +56,44 @@ function ServiceIcon({ service, section, isDark }: { service: ServiceCard, secti ) } - // Use section's default icon - const Icon = (LucideIcons as any)[section.icon] || LucideIcons.Box - return + // Use default icon + return } function ThemeToggle() { 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 = () => { - if (theme === 'light') setTheme('dark') - else if (theme === 'dark') setTheme('system') - else setTheme('light') + if (theme === 'system') { + // If in system mode, switch to the opposite of the current system theme + 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 ( ) } @@ -131,63 +157,91 @@ function StatusIndicator({ status, responseTime, certDaysRemaining }: { ) } -function ServiceSection({ section, services, metrics }: { - section: Section, +type SortOption = 'name' | 'status' | 'response-time' | 'primary-tag' + +function ServiceGrid({ services, metrics, config, sortBy }: { services: ServiceCard[], - metrics: Record + metrics: Record, + config: Config, + sortBy: SortOption }) { const { theme } = useTheme() const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) return ( -
-
-

{section.title}

- -
-
- {services.map((service) => { - const serviceMetrics = metrics[service.monitorName || service.name] || { - status: 'pending', - responseTime: 0, - certDaysRemaining: 0 - } +
+ {services.map((service) => { + const serviceMetrics = metrics[service.monitorName || service.name] || { + status: 'pending', + responseTime: 0, + certDaysRemaining: 0 + } - const style: CardStyle = { - background: service.cardStyle?.background || section.cardStyle?.background || "bg-gray-50 dark:bg-gray-950/30", - iconColor: service.cardStyle?.iconColor || section.cardStyle?.iconColor || "text-gray-600 dark:text-gray-400" - } + const style: CardStyle = { + background: service.cardStyle?.background || "bg-gray-50 dark:bg-gray-950/30", + iconColor: service.cardStyle?.iconColor || "text-gray-600 dark:text-gray-400" + } - return ( - - - - + + + + + {service.description} + + {(sortBy === 'primary-tag' || sortBy === 'status' || sortBy === 'response-time') && ( +
+ {sortBy === 'primary-tag' && ( + + {config.tags?.[primaryTag] || primaryTag} + + )} + {sortBy === 'status' && ( + + {serviceMetrics.status.charAt(0).toUpperCase() + serviceMetrics.status.slice(1)} + + )} + {sortBy === 'response-time' && serviceMetrics.responseTime > 0 && ( + + {serviceMetrics.responseTime}ms + + )} +
+ )} +
+ + + ) + })}
) } @@ -195,7 +249,98 @@ function ServiceSection({ section, services, metrics }: { function App() { const { metrics } = useMetrics() const { config, error } = useConfig() - const { services, sections } = config + const { services } = config + const [searchQuery, setSearchQuery] = useState('') + const [sortBy, setSortBy] = useState('name') + const [selectedTags, setSelectedTags] = useState([]) + const searchInputRef = useRef(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) => { + 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() + 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 (
@@ -214,22 +359,65 @@ function App() { )} - {sections?.length > 0 ? ( - <> - {sections.map((section: Section) => { - const sectionServices = services.filter((s: { category: string }) => s.category === section.id) - if (sectionServices.length === 0) return null - - return ( - - ) - })} - +
+
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="pl-8 pr-8 bg-gray-50 dark:bg-gray-950/30 max-w-md" + /> + {searchQuery && ( + + )} +
+
+ +
+
+ +
+ {availableTags.map(tag => ( + toggleTag(tag)} + className="gap-1 cursor-pointer bg-gray-50 dark:bg-gray-950/30" + > + {config.tags?.[tag] || tag} + {selectedTags.includes(tag) && } + + ))} +
+
+ + {services?.length > 0 ? ( + ) : !error && ( diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..69b64fb --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..a84242c --- /dev/null +++ b/src/components/ui/select.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/index.css b/src/index.css index 92c8440..c69a9da 100644 --- a/src/index.css +++ b/src/index.css @@ -73,7 +73,7 @@ button:focus-visible { @layer base { :root { - --background: 210 20% 96%; + --background: 220 13% 91%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; @@ -89,15 +89,15 @@ button:focus-visible { --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --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%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; + --background: 215 27.9% 16.9%; + --foreground: 220 14.3% 95.9%; + --card: 220.9 39.3% 11%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; @@ -111,7 +111,7 @@ button:focus-visible { --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --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%; --ring: 212.7 26.8% 83.9%; } diff --git a/src/main.tsx b/src/main.tsx index 889c719..f1162b6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,7 +6,7 @@ import { ThemeProvider } from './components/theme-provider' ReactDOM.createRoot(document.getElementById('root')!).render( - + , diff --git a/src/types/services.ts b/src/types/services.ts index 1bcedfc..fda7200 100644 --- a/src/types/services.ts +++ b/src/types/services.ts @@ -21,6 +21,7 @@ export interface ServiceCard { icon?: ReactNode iconName?: string category: CategoryId + tags?: string[] monitorName?: string cardStyle?: CardStyle } @@ -28,4 +29,5 @@ export interface ServiceCard { export interface Config { sections: Section[] services: ServiceCard[] + tags?: { [key: string]: string } } \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index f0c1cab..3e3ba15 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -7,14 +7,14 @@ export default { ], safelist: [ { - pattern: /^bg-(blue|gray|green|red|yellow|purple|pink|indigo|orange)-(50|100|200|300|400|500|600|700|800|900|950)/, - variants: ['hover', 'dark'] + 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'] }, { - pattern: /^text-(blue|gray|green|red|yellow|purple|pink|indigo|orange)-(50|100|200|300|400|500|600|700|800|900|950)/, - variants: ['dark'] + 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'] } - ], + ], theme: { container: { center: true,