Base webui and backend additions
This commit is contained in:
+204
-1
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchStatus } from './api/client'
|
||||
import {
|
||||
applyThemeFromPreference,
|
||||
getSystemPrefersDark,
|
||||
readThemePreference,
|
||||
writeThemePreference,
|
||||
type ThemePreference,
|
||||
} from './theme'
|
||||
import './App.css'
|
||||
|
||||
type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
|
||||
@@ -17,6 +24,25 @@ const SECTION_TITLES: Record<SectionId, string> = {
|
||||
configuration: 'Configuration',
|
||||
}
|
||||
|
||||
type ConfigTabId =
|
||||
| 'overview'
|
||||
| 'users'
|
||||
| 'groups'
|
||||
| 'security'
|
||||
| 'integrations'
|
||||
| 'network'
|
||||
| 'advanced'
|
||||
|
||||
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'groups', label: 'Groups' },
|
||||
{ id: 'security', label: 'Security' },
|
||||
{ id: 'integrations', label: 'Integrations' },
|
||||
{ id: 'network', label: 'Network' },
|
||||
{ id: 'advanced', label: 'Advanced' },
|
||||
]
|
||||
|
||||
function CogIcon() {
|
||||
return (
|
||||
<svg
|
||||
@@ -35,9 +61,179 @@ function CogIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function useThemePreference() {
|
||||
const [themePreference, setThemePreference] =
|
||||
useState<ThemePreference>(readThemePreference)
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeFromPreference(themePreference)
|
||||
|
||||
if (themePreference !== 'auto') {
|
||||
return
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
function handleSystemThemeChange() {
|
||||
applyThemeFromPreference('auto', getSystemPrefersDark(mediaQuery))
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange)
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleSystemThemeChange)
|
||||
}
|
||||
}, [themePreference])
|
||||
|
||||
function updateThemePreference(nextPreference: ThemePreference) {
|
||||
writeThemePreference(nextPreference)
|
||||
setThemePreference(nextPreference)
|
||||
}
|
||||
|
||||
return { themePreference, updateThemePreference }
|
||||
}
|
||||
|
||||
function OverviewConfigTab({
|
||||
draftThemePreference,
|
||||
onDraftThemePreferenceChange,
|
||||
}: {
|
||||
draftThemePreference: ThemePreference
|
||||
onDraftThemePreferenceChange: (preference: ThemePreference) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="config-field">
|
||||
<label htmlFor="theme-preference">Theme</label>
|
||||
<select
|
||||
id="theme-preference"
|
||||
className="theme-select"
|
||||
value={draftThemePreference}
|
||||
onChange={(event) => {
|
||||
onDraftThemePreferenceChange(event.target.value as ThemePreference)
|
||||
}}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<p className="config-hint">
|
||||
Auto follows the browser preference. Light and Dark always use
|
||||
Catppuccin Latte or Mocha.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigTabPanelContent({
|
||||
activeConfigTab,
|
||||
draftThemePreference,
|
||||
onDraftThemePreferenceChange,
|
||||
}: {
|
||||
activeConfigTab: ConfigTabId
|
||||
draftThemePreference: ThemePreference
|
||||
onDraftThemePreferenceChange: (preference: ThemePreference) => void
|
||||
}) {
|
||||
if (activeConfigTab === 'overview') {
|
||||
return (
|
||||
<OverviewConfigTab
|
||||
draftThemePreference={draftThemePreference}
|
||||
onDraftThemePreferenceChange={onDraftThemePreferenceChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'security') {
|
||||
return (
|
||||
<p className="config-empty">
|
||||
Service SSH keys and related credentials will live here — add keys with
|
||||
a description and an expiry time limit or non-expiry.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return <p className="config-empty">Coming soon</p>
|
||||
}
|
||||
|
||||
function ConfigurationPanel({
|
||||
themePreference,
|
||||
onThemePreferenceChange,
|
||||
}: {
|
||||
themePreference: ThemePreference
|
||||
onThemePreferenceChange: (preference: ThemePreference) => void
|
||||
}) {
|
||||
const [activeConfigTab, setActiveConfigTab] =
|
||||
useState<ConfigTabId>('overview')
|
||||
const [draftThemePreference, setDraftThemePreference] =
|
||||
useState<ThemePreference>(themePreference)
|
||||
|
||||
useEffect(() => {
|
||||
setDraftThemePreference(themePreference)
|
||||
}, [themePreference])
|
||||
|
||||
const isDirty = draftThemePreference !== themePreference
|
||||
|
||||
return (
|
||||
<div className="config-panel">
|
||||
<div className="config-tabs" role="tablist" aria-label="Configuration">
|
||||
{CONFIG_TABS.map((tab) => {
|
||||
const isSelected = activeConfigTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`config-tab-${tab.id}`}
|
||||
className={
|
||||
isSelected ? 'config-tab config-tab-active' : 'config-tab'
|
||||
}
|
||||
aria-selected={isSelected}
|
||||
aria-controls="config-tab-panel"
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
onClick={() => setActiveConfigTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="config-tab-panel"
|
||||
role="tabpanel"
|
||||
id="config-tab-panel"
|
||||
aria-labelledby={`config-tab-${activeConfigTab}`}
|
||||
>
|
||||
<ConfigTabPanelContent
|
||||
activeConfigTab={activeConfigTab}
|
||||
draftThemePreference={draftThemePreference}
|
||||
onDraftThemePreferenceChange={setDraftThemePreference}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="config-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
disabled={!isDirty}
|
||||
onClick={() => setDraftThemePreference(themePreference)}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-save"
|
||||
disabled={!isDirty}
|
||||
onClick={() => onThemePreferenceChange(draftThemePreference)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('overview')
|
||||
const [backendVersion, setBackendVersion] = useState('…')
|
||||
const { themePreference, updateThemePreference } = useThemePreference()
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
@@ -110,7 +306,14 @@ function App() {
|
||||
|
||||
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
|
||||
<h2>{SECTION_TITLES[activeSection]}</h2>
|
||||
<p>Coming soon</p>
|
||||
{activeSection === 'configuration' ? (
|
||||
<ConfigurationPanel
|
||||
themePreference={themePreference}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
/>
|
||||
) : (
|
||||
<p>Coming soon</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user