diff --git a/config-server/server.js b/config-server/server.js
new file mode 100644
index 0000000..0085d42
--- /dev/null
+++ b/config-server/server.js
@@ -0,0 +1,69 @@
+const express = require('express')
+const fs = require('fs/promises')
+const path = require('path')
+const yaml = require('js-yaml')
+const cors = require('cors')
+const bodyParser = require('body-parser')
+
+const app = express()
+const PORT = process.env.CONFIG_PORT || 3012
+
+// CORS configuration
+app.use(cors({
+ origin: ['https://start.kent.pw', 'https://auth.kent.pw'],
+ credentials: true,
+ methods: ['GET', 'POST', 'OPTIONS'],
+ allowedHeaders: [
+ 'Content-Type',
+ 'Authorization',
+ 'Remote-User',
+ 'Remote-Name',
+ 'Remote-Email',
+ 'Remote-Groups',
+ 'X-Requested-With'
+ ],
+ exposedHeaders: ['Set-Cookie']
+}))
+
+// Options preflight
+app.options('/api/config/update', cors())
+
+// Raw body parser for YAML
+app.use(bodyParser.text({ type: 'text/yaml' }))
+
+// Authentication middleware
+const requireAuth = (req, res, next) => {
+ const user = req.headers['remote-user']
+ if (!user) {
+ return res.status(401).json({ error: 'Unauthorized' })
+ }
+ next()
+}
+
+app.post('/api/config/update', requireAuth, async (req, res) => {
+ try {
+ // Get the raw YAML content from the request body
+ const yamlContent = req.body
+
+ // Validate the YAML by trying to parse it
+ yaml.load(yamlContent)
+
+ // Write to the config file
+ const configPath = path.join('/var/www/html/homepage/public', 'config.yaml')
+ await fs.writeFile(configPath, yamlContent, 'utf8')
+
+ res.status(200).json({ message: 'Configuration updated successfully' })
+ } catch (error) {
+ console.error('Error updating configuration:', error)
+ res.status(500).json({ error: 'Failed to update configuration: ' + error.message })
+ }
+})
+
+// Health check endpoint
+app.get('/health', (req, res) => {
+ res.status(200).json({ status: 'ok' })
+})
+
+app.listen(PORT, () => {
+ console.log(`Config server listening on port ${PORT}`)
+})
\ No newline at end of file
diff --git a/index.html b/index.html
index e4b78ea..202b284 100644
--- a/index.html
+++ b/index.html
@@ -2,9 +2,9 @@
-
+
- Vite + React + TS
+ Kent.pw Homepage
diff --git a/package-lock.json b/package-lock.json
index f3b85da..960ea1d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,16 @@
"name": "homepage",
"version": "0.0.0",
"dependencies": {
+ "@monaco-editor/react": "^4.6.0",
+ "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
+ "@types/js-yaml": "^4.0.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "js-yaml": "^4.1.0",
"lucide-react": "^0.474.0",
"next-themes": "^0.4.4",
"react": "^19.0.0",
@@ -1082,6 +1086,32 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz",
+ "integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==",
+ "license": "MIT",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.21.0 < 1"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
+ "integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
+ "license": "MIT",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.4.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1212,6 +1242,42 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz",
+ "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@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-portal": "1.1.4",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-slot": "1.1.2",
+ "@radix-ui/react-use-controllable-state": "1.1.0",
+ "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-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -2039,6 +2105,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/js-yaml": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2411,7 +2483,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
@@ -3464,7 +3535,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -3648,6 +3718,13 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/monaco-editor": {
+ "version": "0.52.2",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
+ "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4361,6 +4438,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
diff --git a/package.json b/package.json
index 47c49b4..c5586a9 100644
--- a/package.json
+++ b/package.json
@@ -10,12 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
+ "@monaco-editor/react": "^4.6.0",
+ "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
+ "@types/js-yaml": "^4.0.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "js-yaml": "^4.1.0",
"lucide-react": "^0.474.0",
"next-themes": "^0.4.4",
"react": "^19.0.0",
diff --git a/public/config.yaml b/public/config.yaml
new file mode 100644
index 0000000..1408396
--- /dev/null
+++ b/public/config.yaml
@@ -0,0 +1,128 @@
+services:
+ - name: "Portainer"
+ description: "Container Management"
+ url: "https://portainer.kent.pw"
+ iconName: "portainer"
+ category: "system"
+
+ - name: "Gitea"
+ description: "Git Server"
+ url: "https://gitea.kent.pw"
+ iconName: "gitea"
+ category: "system"
+
+ - name: "Cockpit"
+ description: "Server Management"
+ url: "https://cockpit.kent.pw"
+ iconName: "cockpit"
+ category: "system"
+
+ - name: "DiskStation"
+ description: "Synology NAS"
+ url: "https://diskstation.kent.pw"
+ iconName: "synology"
+ category: "system"
+ monitorName: "Synology Diskstation"
+
+ - name: "Plex"
+ description: "Media Server"
+ url: "https://plex.kent.pw"
+ iconName: "plex"
+ category: "media"
+
+ - name: "Sonarr"
+ description: "TV Show Management"
+ url: "https://sonarr.kent.pw"
+ iconName: "sonarr"
+ category: "media"
+
+ - 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"
+
+ - name: "Uptime"
+ description: "Service Monitoring"
+ url: "https://uptime.kent.pw"
+ iconName: "uptime-kuma"
+ category: "monitoring"
+ monitorName: "Uptime Kuma"
+
+ - name: "AdGuard"
+ description: "Network Ad Blocking"
+ url: "https://adguard.kent.pw"
+ iconName: "adguard-home"
+ category: "system"
+ monitorName: "Adguard Home"
+
+ - name: "NocoDB"
+ description: "Database Platform"
+ url: "https://noco.kent.pw"
+ iconName: "nocodb"
+ category: "tools"
+ monitorName: "NocoDB"
+
+ - 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"
+
+ - name: "Speedtest"
+ description: "Network Speed Monitor"
+ url: "https://speedtest.kent.pw"
+ iconName: "speedtest-tracker"
+ category: "monitoring"
+ monitorName: "Speedtest Tracker"
+
+ - name: "Netdata"
+ description: "Monitoring Dashboard"
+ url: "https://netdata.kent.pw"
+ iconName: "netdata"
+ category: "monitoring"
+ monitorName: "Netdata"
+
+ - name: "Drive"
+ description: "File Storage"
+ url: "https://drive.kent.pw"
+ iconName: "synology"
+ category: "tools"
+ monitorName: "Synology Drive"
+
+ - name: "Dashboard"
+ description: "ACOT Dashboard"
+ url: "https://dashboard.kent.pw"
+ iconName: "lucide-layout-dashboard"
+ category: "acot"
+
+ - name: "Inventory"
+ description: "ACOT Inventory"
+ url: "https://inventory.kent.pw"
+ iconName: "lucide-box"
+ category: "acot"
+
+ - name: "Homebridge"
+ description: "HomeKit Bridge"
+ url: "https://homebridge.kent.pw"
+ iconName: "homebridge"
+ category: "home"
+
+ - name: "Scrypted"
+ description: "Smart Home Integration"
+ url: "https://scrypted.kent.pw"
+ iconName: "scrypted"
+ category: "home"
\ No newline at end of file
diff --git a/public/house.svg b/public/house.svg
new file mode 100644
index 0000000..2c76a31
--- /dev/null
+++ b/public/house.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/vite.svg b/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/server/api/config.js b/server/api/config.js
new file mode 100644
index 0000000..f31a418
--- /dev/null
+++ b/server/api/config.js
@@ -0,0 +1,27 @@
+const express = require('express')
+const fs = require('fs/promises')
+const path = require('path')
+const yaml = require('js-yaml')
+
+const router = express.Router()
+
+router.post('/api/config', async (req, res) => {
+ try {
+ // Get the raw YAML content from the request body
+ const yamlContent = req.body
+
+ // Validate the YAML by trying to parse it
+ yaml.load(yamlContent)
+
+ // Write to the config file
+ const configPath = path.join(process.cwd(), 'public', 'config.yaml')
+ await fs.writeFile(configPath, yamlContent, 'utf8')
+
+ res.status(200).json({ message: 'Configuration updated successfully' })
+ } catch (error) {
+ console.error('Error updating configuration:', error)
+ res.status(500).json({ error: 'Failed to update configuration' })
+ }
+})
+
+module.exports = router
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 51ec6a0..9a52fdc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,18 +5,13 @@ import { Moon, Sun, Monitor} from "lucide-react"
import * as LucideIcons from "lucide-react"
import { useTheme } from "@/components/theme-provider"
import { useMetrics } from "./hooks/useMetrics"
+import { useConfig } from "./hooks/useConfig"
import { ServiceStatus } from "./types/metrics"
+import { ServiceCard } from "./types/services"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
-
-interface ServiceCard {
- name: string
- description: string
- url: string
- icon?: React.ReactNode
- iconName?: string
- category: 'system' | 'media' | 'monitoring' | 'tools' | 'acot' | 'home'
- monitorName?: string
-}
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { AlertCircle } from "lucide-react"
+import { ConfigEditor } from './components/config-editor'
const getIconUrl = (name: string | undefined, isDark: boolean = false): string => {
if (!name) return ''
@@ -75,149 +70,6 @@ function ServiceIcon({ service, isDark }: { service: ServiceCard, isDark: boolea
return
}
-const services: ServiceCard[] = [
- {
- name: "Portainer",
- description: "Container Management",
- url: "https://portainer.kent.pw",
- iconName: "portainer",
- category: "system"
- },
- {
- name: "Gitea",
- description: "Git Server",
- url: "https://gitea.kent.pw",
- iconName: "gitea",
- category: "system"
- },
- {
- name: "Cockpit",
- description: "Server Management",
- url: "https://cockpit.kent.pw",
- iconName: "cockpit",
- category: "system"
- },
- {
- name: "DiskStation",
- description: "Synology NAS",
- url: "https://diskstation.kent.pw",
- iconName: "synology",
- category: "system",
- monitorName: "Synology Diskstation"
- },
- {
- name: "Plex",
- description: "Media Server",
- url: "https://plex.kent.pw",
- iconName: "plex",
- category: "media"
- },
- {
- name: "Sonarr",
- description: "TV Show Management",
- url: "https://sonarr.kent.pw",
- iconName: "sonarr",
- category: "media"
- },
- {
- 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"
- },
- {
- name: "Uptime",
- description: "Service Monitoring",
- url: "https://uptime.kent.pw",
- iconName: "uptime-kuma",
- category: "monitoring",
- monitorName: "Uptime Kuma"
- },
- {
- name: "AdGuard",
- description: "Network Ad Blocking",
- url: "https://adguard.kent.pw",
- iconName: "adguard-home",
- category: "system",
- monitorName: "Adguard Home"
- },
- {
- name: "NocoDB",
- description: "Database Platform",
- url: "https://noco.kent.pw",
- iconName: "nocodb",
- category: "tools",
- monitorName: "NocoDB"
- },
- {
- 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"
- },
- {
- name: "Speedtest",
- description: "Network Speed Monitor",
- url: "https://speedtest.kent.pw",
- iconName: "speedtest-tracker",
- category: "monitoring",
- monitorName: "Speedtest Tracker"
- },
- {
- name: "Drive",
- description: "File Storage",
- url: "https://drive.kent.pw",
- iconName: "synology",
- category: "tools",
- monitorName: "Synology Drive"
- },
- {
- name: "Dashboard",
- description: "ACOT Dashboard",
- url: "https://dashboard.kent.pw",
- iconName: "lucide-layout-dashboard",
- category: "acot"
- },
- {
- name: "Inventory",
- description: "ACOT Inventory",
- url: "https://inventory.kent.pw",
- iconName: "lucide-box",
- category: "acot"
- },
- {
- name: "Homebridge",
- description: "HomeKit Bridge",
- url: "https://homebridge.kent.pw",
- iconName: "homebridge",
- category: "home"
- },
- {
- name: "Scrypted",
- description: "Smart Home Integration",
- url: "https://scrypted.kent.pw",
- iconName: "scrypted",
- category: "home"
- }
-]
-
function ThemeToggle() {
const { theme, setTheme } = useTheme()
@@ -341,6 +193,8 @@ function ServiceSection({ title, services, metrics }: {
return "bg-emerald-50 dark:bg-emerald-950/30"
case "Speedtest":
return "bg-blue-50 dark:bg-blue-950/30"
+ case "Netdata":
+ return "bg-purple-50 dark:bg-purple-950/30"
default:
return "bg-gray-50 dark:bg-gray-950/30"
}
@@ -375,6 +229,8 @@ function ServiceSection({ title, services, metrics }: {
return "text-emerald-600 dark:text-emerald-400"
case "Speedtest":
return "text-blue-600 dark:text-blue-400"
+ case "Netdata":
+ return "text-purple-600 dark:text-purple-400"
default:
return "text-gray-600 dark:text-gray-400"
}
@@ -413,8 +269,12 @@ function ServiceSection({ title, services, metrics }: {
)
}
+
function App() {
const { metrics } = useMetrics()
+ const { config, error, saveConfig } = useConfig()
+ const services = config.services
+
const systemServices = services.filter(s => s.category === 'system')
const mediaServices = services.filter(s => s.category === 'media')
const monitoringServices = services.filter(s => s.category === 'monitoring')
@@ -426,15 +286,41 @@ function App() {
Kent.pw Homepage
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {error && (
+
+
+ Error
+
+ Failed to load configuration: {error}
+
+
+ )}
+
+ {services.length > 0 ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : !error && (
+
+
+ Loading
+
+ Loading service configuration...
+
+
+ )}
+
)
}
diff --git a/src/components/config-editor.tsx b/src/components/config-editor.tsx
new file mode 100644
index 0000000..37b755d
--- /dev/null
+++ b/src/components/config-editor.tsx
@@ -0,0 +1,86 @@
+import { useEffect, useState } from 'react'
+import Editor from '@monaco-editor/react'
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
+import { Button } from '@/components/ui/button'
+import { Settings2 } from 'lucide-react'
+import yaml from 'js-yaml'
+import { ServiceCard } from '@/types/services'
+
+interface ConfigEditorProps {
+ currentConfig: { services: ServiceCard[] }
+ onSave: (newConfig: string) => Promise
+}
+
+export function ConfigEditor({ currentConfig, onSave }: ConfigEditorProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [editorContent, setEditorContent] = useState('')
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ try {
+ const yamlString = yaml.dump(currentConfig, { indent: 2 })
+ setEditorContent(yamlString)
+ setError(null)
+ } catch (err) {
+ setError('Failed to convert configuration to YAML')
+ console.error('Error converting config to YAML:', err)
+ }
+ }, [currentConfig])
+
+ const handleSave = async () => {
+ try {
+ // Validate YAML
+ yaml.load(editorContent)
+
+ // If validation passes, save
+ await onSave(editorContent)
+ setIsOpen(false)
+ setError(null)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Invalid YAML format')
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ Edit Configuration
+
+
+
setEditorContent(value || '')}
+ options={{
+ minimap: { enabled: false },
+ fontSize: 14,
+ lineNumbers: 'on',
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ wrappingIndent: 'indent',
+ }}
+ />
+ {error && (
+ {error}
+ )}
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 0000000..5afd41d
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..02942db
--- /dev/null
+++ b/src/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ }
+)
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+
+
+ Close
+
+ {children}
+
+
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/src/hooks/useConfig.ts b/src/hooks/useConfig.ts
new file mode 100644
index 0000000..aef2b2a
--- /dev/null
+++ b/src/hooks/useConfig.ts
@@ -0,0 +1,65 @@
+import { useState, useEffect } from 'react'
+import yaml from 'js-yaml'
+import { ServiceCard } from '../types/services'
+
+interface Config {
+ services: ServiceCard[]
+}
+
+export function useConfig() {
+ const [config, setConfig] = useState({ services: [] })
+ const [error, setError] = useState(null)
+
+ const loadConfig = async () => {
+ try {
+ const response = await fetch('/config.yaml')
+ if (!response.ok) {
+ throw new Error('Failed to load configuration')
+ }
+ const text = await response.text()
+ const parsed = yaml.load(text) as Config
+ setConfig(parsed)
+ setError(null)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load configuration')
+ console.error('Error loading configuration:', err)
+ }
+ }
+
+ const saveConfig = async (newConfig: string) => {
+ try {
+ const response = await fetch('/api/config/update', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'text/yaml',
+ },
+ body: newConfig,
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
+ throw new Error(errorData.error || 'Failed to save configuration')
+ }
+
+ // Reload the config after saving
+ await loadConfig()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save configuration')
+ console.error('Error saving configuration:', err)
+ throw err
+ }
+ }
+
+ useEffect(() => {
+ // Load config immediately
+ loadConfig()
+
+ // Set up polling for config changes in production
+ const interval = setInterval(loadConfig, 30000) // Check every 30 seconds
+
+ return () => clearInterval(interval)
+ }, [])
+
+ return { config, error, saveConfig }
+}
\ No newline at end of file
diff --git a/src/types/services.ts b/src/types/services.ts
new file mode 100644
index 0000000..f40de2a
--- /dev/null
+++ b/src/types/services.ts
@@ -0,0 +1,11 @@
+import { ReactNode } from 'react'
+
+export interface ServiceCard {
+ name: string
+ description: string
+ url: string
+ icon?: ReactNode
+ iconName?: string
+ category: 'system' | 'media' | 'monitoring' | 'tools' | 'acot' | 'home'
+ monitorName?: string
+}
\ No newline at end of file