Rework layout, add search/filtering, replace category sections with tags, improve light/dark mode toggle
This commit is contained in:
65
package-lock.json
generated
65
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"]
|
||||||
344
src/App.tsx
344
src/App.tsx
@@ -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}
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
className="pl-8 pr-8 bg-gray-50 dark:bg-gray-950/30 max-w-md"
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
|
||||||
return (
|
<div className="flex flex-wrap gap-2">
|
||||||
<ServiceSection
|
{availableTags.map(tag => (
|
||||||
key={section.id}
|
<Badge
|
||||||
section={section}
|
key={tag}
|
||||||
services={sectionServices}
|
variant={selectedTags.includes(tag) ? "default" : "outline"}
|
||||||
metrics={metrics}
|
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" />
|
||||||
|
|||||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal 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 }
|
||||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user