From 68dce7ee2a5ca4762a0870bae72397dc37d1e4fe Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 8 Feb 2025 14:02:16 -0500 Subject: [PATCH] Add config file and text editor + update server --- config-server/server.js | 69 ++++++++++ index.html | 4 +- package-lock.json | 87 ++++++++++++- package.json | 4 + public/config.yaml | 128 +++++++++++++++++++ public/house.svg | 1 + public/vite.svg | 1 - server/api/config.js | 27 ++++ src/App.tsx | 208 +++++++------------------------ src/components/config-editor.tsx | 86 +++++++++++++ src/components/ui/alert.tsx | 59 +++++++++ src/components/ui/sheet.tsx | 138 ++++++++++++++++++++ src/hooks/useConfig.ts | 65 ++++++++++ src/types/services.ts | 11 ++ 14 files changed, 722 insertions(+), 166 deletions(-) create mode 100644 config-server/server.js create mode 100644 public/config.yaml create mode 100644 public/house.svg delete mode 100644 public/vite.svg create mode 100644 server/api/config.js create mode 100644 src/components/config-editor.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/hooks/useConfig.ts create mode 100644 src/types/services.ts 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