Settings page: themes (Dark, Light, Cyberpunk, Retro) with xterm palettes and hub-synced choice, change password, access tokens, about
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
254e0cdd58
commit
a4f39859b6
@@ -220,6 +220,13 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
- **Terminal extras** — search, copy-mode, font/theme, split panes, upload via
|
||||
drag-drop into cwd.
|
||||
|
||||
- **Settings + themes** — **shipped 2026-09-18** (`Settings.tsx`, `themes.ts`): Settings
|
||||
page with Appearance (Dark, Light, Cyberpunk, Retro — CSS variables on `<html>` plus a
|
||||
matching xterm palette, applied before first paint from localStorage and synced hub-side
|
||||
via `GET/PUT /api/prefs/ui` so the choice follows the login), Account (change password,
|
||||
`POST /api/password`, current password required), Access tokens (list/revoke the tokens
|
||||
hubs hold), About (version, host, arch, uptime, certificate).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Idle-session reaping policy (never / after N days) — leaning never; it's a
|
||||
|
||||
@@ -143,6 +143,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
||||
m.HandleFunc("GET /api/procs", s.auth(s.procList))
|
||||
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
|
||||
m.HandleFunc("POST /api/password", s.auth(s.changePassword))
|
||||
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
|
||||
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
|
||||
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
||||
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
||||
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
||||
@@ -1190,6 +1193,61 @@ func (s *Server) procSignal(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// ---- account ----
|
||||
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Current, Password string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Current) {
|
||||
jsonErr(w, 403, "current password is wrong")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 12 {
|
||||
jsonErr(w, 400, "password must be at least 12 characters")
|
||||
return
|
||||
}
|
||||
h, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.cfg.Update(func(c *config.Config) { c.PasswordHash = h }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("password changed (from %s)", clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// ---- ui prefs (theme) ----
|
||||
|
||||
func (s *Server) getUIPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) { raw = st.UI })
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(io.LimitReader(r.Body, 16<<10))
|
||||
if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' {
|
||||
jsonErr(w, 400, "expected a JSON object")
|
||||
return
|
||||
}
|
||||
if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
||||
|
||||
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -19,6 +19,7 @@ type State struct {
|
||||
Terminals []Terminal `json:"terminals"`
|
||||
Sessions map[string]time.Time `json:"sessions"` // token -> expiry
|
||||
Dashboard json.RawMessage `json:"dashboard,omitempty"` // UI layout prefs, opaque to the server
|
||||
UI json.RawMessage `json:"ui,omitempty"` // theme etc., opaque to the server
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
|
||||
+5
-1
@@ -2,12 +2,16 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, Me, Unauthenticated } from './api'
|
||||
import Login from './Login'
|
||||
import Shell from './Shell'
|
||||
import { applyTheme, currentThemeId } from './themes'
|
||||
|
||||
export default function App() {
|
||||
const [me, setMe] = useState<Me | null | undefined>(undefined)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try { setMe(await api.meHere()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
|
||||
try {
|
||||
const m = await api.meHere(); setMe(m)
|
||||
api.uiPrefs().then((p) => { if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) }).catch(() => { /* keep local */ })
|
||||
} catch (e) { if (e instanceof Unauthenticated) setMe(null) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { api, Me } from './api'
|
||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||
import { Btn, Loading, toast } from './ui'
|
||||
|
||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||
|
||||
// Small painted preview of a theme: rail, header, a tile and a bar, in its own colours.
|
||||
function Preview({ vars }: { vars: Record<string, string> }) {
|
||||
const v = (k: string) => vars[k]
|
||||
return (
|
||||
<div className="tprev" style={{ background: v('--bg'), borderColor: v('--border') }}>
|
||||
<div className="tprev-head" style={{ background: v('--bg2'), borderColor: v('--border') }}><span style={{ color: v('--accent') }}>Ham Lab</span><span className="tprev-dot" style={{ background: v('--ok') }} /></div>
|
||||
<div className="tprev-body">
|
||||
<div className="tprev-rail" style={{ background: v('--bg2'), borderColor: v('--border') }}>
|
||||
<span style={{ background: v('--bg3'), boxShadow: `inset 2px 0 0 ${v('--accent')}` }} /><span /><span />
|
||||
</div>
|
||||
<div className="tprev-main">
|
||||
<div className="tprev-tile" style={{ background: v('--bg2'), borderColor: v('--border') }}>
|
||||
<i style={{ color: v('--muted') }}>CPU</i><b style={{ color: v('--fg') }}>12%</b>
|
||||
<div className="tprev-bar" style={{ background: v('--track') }}><div style={{ width: '40%', background: v('--s1') }} /></div>
|
||||
</div>
|
||||
<div className="tprev-term" style={{ background: v('--term-bg'), color: v('--accent2') }}>$ _</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Settings({ me }: { me: Me }) {
|
||||
const [theme, setTheme] = useState(currentThemeId())
|
||||
const [tokens, setTokens] = useState<{ name: string; created: string }[] | null>(null)
|
||||
const [cur, setCur] = useState('')
|
||||
const [pw1, setPw1] = useState('')
|
||||
const [pw2, setPw2] = useState('')
|
||||
const [pwErr, setPwErr] = useState('')
|
||||
|
||||
const loadTokens = useCallback(async () => { try { setTokens(await api.tokens()) } catch { setTokens([]) } }, [])
|
||||
useEffect(() => { loadTokens() }, [loadTokens])
|
||||
|
||||
const pick = async (id: string) => {
|
||||
applyTheme(id); setTheme(id)
|
||||
try { await api.saveUIPrefs({ theme: id }) } catch { /* local still applied */ }
|
||||
}
|
||||
|
||||
async function changePassword(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setPwErr('')
|
||||
if (pw1 !== pw2) { setPwErr('New passwords differ.'); return }
|
||||
if (pw1.length < 12) { setPwErr('At least 12 characters.'); return }
|
||||
try { await api.changePassword(cur, pw1); toast('password changed'); setCur(''); setPw1(''); setPw2('') }
|
||||
catch (err) { setPwErr(err instanceof Error ? err.message : 'failed') }
|
||||
}
|
||||
async function revoke(name: string) {
|
||||
if (!window.confirm(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
||||
try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dash settings">
|
||||
<div className="dash-bar"><h2 className="section">Appearance</h2><span className="spacer" /><span className="muted small">follows you to every device you sign in on</span></div>
|
||||
<div className="themes">
|
||||
{THEMES.map((t) => (
|
||||
<button key={t.id} className={'theme-card' + (theme === t.id ? ' on' : '')} onClick={() => pick(t.id)}>
|
||||
<Preview vars={t.vars} />
|
||||
<div className="theme-name">{t.name}{theme === t.id && <span className="badge">active</span>}</div>
|
||||
<div className="muted small">{t.blurb}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Account · {me.host}</h2></div>
|
||||
<form className="card addform" onSubmit={changePassword}>
|
||||
<label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label>
|
||||
<label>New password <input type="password" value={pw1} onChange={(e) => setPw1(e.target.value)} autoComplete="new-password" required /></label>
|
||||
<label>New password again <input type="password" value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" required /></label>
|
||||
{pwErr && <div className="error">{pwErr}</div>}
|
||||
<div className="svc-actions"><button className="btn on" disabled={!cur || !pw1 || !pw2}>Change password</button><span className="muted small">Changes the login for this host only. Other machines keep their own password. Existing sessions stay signed in.</span></div>
|
||||
</form>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||
<section className="card">
|
||||
<div className="muted small" style={{ marginBottom: 8 }}>Tokens let another Ham Lab (a hub) manage this host. They're issued when a hub adds this machine and revoked when it removes it.</div>
|
||||
{tokens === null && <Loading label="Loading…" />}
|
||||
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
|
||||
{tokens && tokens.map((t) => (
|
||||
<div className="row" key={t.name}>
|
||||
<span className="k mono">{t.name}</span>
|
||||
<span className="muted small">issued {new Date(t.created).toLocaleString()}</span>
|
||||
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">About</h2></div>
|
||||
<section className="card">
|
||||
<div className="props">
|
||||
<div><span className="k">Version</span><span className="mono">{me.version}</span></div>
|
||||
<div><span className="k">Host</span><span>{me.host} · {me.arch}</span></div>
|
||||
<div><span className="k">Runs as</span><span>{me.user}</span></div>
|
||||
<div><span className="k">Uptime</span><span>{fmtUptime(me.uptime)}</span></div>
|
||||
{me.fingerprint && <div><span className="k">Certificate</span><span className="mono small">{me.fingerprint}</span></div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+6
-3
@@ -11,6 +11,7 @@ import Disks from './Disks'
|
||||
import Files from './Files'
|
||||
import Procs from './Procs'
|
||||
import Machines from './Machines'
|
||||
import Settings from './Settings'
|
||||
import MachineSwitcher from './MachineSwitcher'
|
||||
import { MachineStatus, setMachine } from './api'
|
||||
import Power from './Power'
|
||||
@@ -27,15 +28,16 @@ const GROUPS: Group[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
||||
{ id: 'procs', label: 'Processes', icon: '≡', page: true },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
||||
{ id: 'services', label: 'Services', icon: '◈', page: true },
|
||||
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪', page: true },
|
||||
{ id: 'disks', label: 'Disks', icon: '▤', page: true },
|
||||
{ id: 'files', label: 'Files', icon: FolderIcon, page: true },
|
||||
{ id: 'machines', label: 'Machines', icon: '⌂', page: true },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙', page: true },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines'
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
@@ -150,6 +152,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
{view === 'settings' && <Settings me={hubMe} />}
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{view === 'procs' && <Procs />}
|
||||
@@ -180,7 +183,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,13 +14,7 @@ interface Props {
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
const THEME = {
|
||||
background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78',
|
||||
black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff',
|
||||
magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198',
|
||||
brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
||||
}
|
||||
import { currentThemeId, themeById } from './themes'
|
||||
|
||||
export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
@@ -34,7 +28,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
useEffect(() => {
|
||||
const el = box.current!
|
||||
const term = new Terminal({
|
||||
theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
theme: themeById(currentThemeId()).xterm, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14,
|
||||
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
|
||||
})
|
||||
@@ -106,6 +100,9 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } }
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
|
||||
const onTheme = (e: Event) => { term.options.theme = (e as CustomEvent).detail.xterm }
|
||||
window.addEventListener('hamlab:theme', onTheme)
|
||||
|
||||
register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } })
|
||||
|
||||
return () => {
|
||||
@@ -113,6 +110,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
closed = true
|
||||
clearTimeout(retryTimer)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
window.removeEventListener('hamlab:theme', onTheme)
|
||||
ro.disconnect()
|
||||
ws?.close()
|
||||
term.dispose()
|
||||
|
||||
@@ -165,6 +165,11 @@ export const api = {
|
||||
addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true),
|
||||
updateMachine: (name: string) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update`, undefined, true),
|
||||
updateAllMachines: () => req<PushResult[]>('POST', '/api/machines-update-all', undefined, true),
|
||||
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||||
tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true),
|
||||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||
uiPrefs: () => req<{ theme?: string }>('GET', '/api/prefs/ui', undefined, true),
|
||||
saveUIPrefs: (p: { theme?: string }) => req<void>('PUT', '/api/prefs/ui', p, true),
|
||||
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
|
||||
terminals: () => req<Term[]>('GET', '/api/terminals'),
|
||||
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { applyTheme, currentThemeId } from './themes'
|
||||
applyTheme(currentThemeId())
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import './styles.css'
|
||||
|
||||
|
||||
+39
-10
@@ -3,11 +3,12 @@
|
||||
--fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff;
|
||||
--ok: #3fb950; --bad: #ff7b72;
|
||||
--s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18);
|
||||
--btn-fg: #111; --term-bg: var(--term-bg); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--header-h: 44px; --footer-h: 28px; --rail-w: 220px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overscroll-behavior: none; }
|
||||
body { background: var(--bg); color: var(--fg); font: 14px/1.4 var(--font); overscroll-behavior: none; transition: background .2s, color .2s; }
|
||||
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
|
||||
.spacer { flex: 1; }
|
||||
.muted { color: var(--muted); }
|
||||
@@ -18,7 +19,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.login-logo { width: 160px; height: 160px; align-self: center; border-radius: 50%; margin-bottom: 4px; }
|
||||
.login h1 { text-align: center; margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; }
|
||||
.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||
.login button, .primary { padding: 10px 12px; background: var(--accent); color: #111; border-radius: 6px; font-weight: 600; }
|
||||
.login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; }
|
||||
.login button:disabled { opacity: .5; }
|
||||
.error { color: var(--bad); font-size: 13px; }
|
||||
|
||||
@@ -57,7 +58,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.sdot.run { background: var(--ok); }
|
||||
|
||||
/* main + terminal */
|
||||
.main { flex: 1; min-width: 0; position: relative; background: #0d1117; }
|
||||
.main { flex: 1; min-width: 0; position: relative; background: var(--term-bg); }
|
||||
.term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
|
||||
.term-inner { height: 100%; }
|
||||
.term .xterm { height: 100%; }
|
||||
@@ -66,7 +67,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
/* key bar (touch devices / narrow screens) */
|
||||
.keybar { display: none; gap: 4px; padding: 4px 6px; background: var(--bg2); border-top: 1px solid var(--border); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; }
|
||||
.keybar button { flex: 0 0 auto; padding: 8px 10px; min-width: 40px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
.keybar button.on { background: var(--accent); color: #111; border-color: var(--accent); }
|
||||
.keybar button.on { background: var(--accent); color: var(--btn-fg); border-color: var(--accent); }
|
||||
@media (pointer: coarse), (max-width: 800px) { .keybar { display: flex; } }
|
||||
|
||||
/* mobile: rail becomes a drawer */
|
||||
@@ -114,7 +115,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.dash-bar .hint { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.dash-bar select.btn { padding: 3px 6px; }
|
||||
.btn { padding: 5px 10px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-size: 12px; color: var(--fg); }
|
||||
.btn.on { background: var(--accent); color: #111; border-color: var(--accent); }
|
||||
.btn.on { background: var(--accent); color: var(--btn-fg); border-color: var(--accent); }
|
||||
.mod-tools { display: flex; gap: 4px; margin: -4px -4px 6px auto; justify-content: flex-end; }
|
||||
.mod-tools button { padding: 2px 7px; background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; font-size: 12px; color: var(--muted); }
|
||||
.mod-tools button:hover:not(:disabled) { color: var(--fg); } .mod-tools button:disabled { opacity: .35; cursor: default; }
|
||||
@@ -150,8 +151,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
|
||||
.tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
|
||||
.unit-file { margin: 0; padding: 10px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||
.logs { flex: 1; min-height: 240px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: #0d1117; overflow: hidden; }
|
||||
.unit-file { margin: 0; padding: 10px; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||
.logs { flex: 1; min-height: 240px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: var(--term-bg); overflow: hidden; }
|
||||
.logs-bar { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-bottom: 1px solid var(--border); font-size: 12px; background: var(--bg2); }
|
||||
.log-lines { flex: 1; overflow: auto; padding: 6px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.log { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; }
|
||||
@@ -177,8 +178,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.cron-detail .back { display: none; align-self: flex-start; }
|
||||
.cron-detail h2 { margin: 0; font-size: 15px; font-weight: 600; font-family: ui-monospace, monospace; word-break: break-all; white-space: pre-wrap; }
|
||||
.notice { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; color: var(--muted); background: var(--bg2); }
|
||||
.editor { width: 100%; min-height: 260px; padding: 10px; background: #0d1117; color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; resize: vertical; }
|
||||
.run-out { margin: 0; padding: 10px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; }
|
||||
.editor { width: 100%; min-height: 260px; padding: 10px; background: var(--term-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; resize: vertical; }
|
||||
.run-out { margin: 0; padding: 10px; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; }
|
||||
@media (max-width: 800px) {
|
||||
.cron-list { width: 100%; border-right: 0; } .has-sel .cron-list { display: none; }
|
||||
.cron-detail .back { display: inline-block; }
|
||||
@@ -325,7 +326,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.phead { background: var(--bg2); cursor: default; padding: 0 10px; border-top: 1px solid var(--border); }
|
||||
.th { padding: 6px 0; text-align: left; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } .th.num { text-align: right; } .th.on { color: var(--fg); }
|
||||
.pdetail { position: sticky; bottom: 0; z-index: 3; border-top: 1px solid var(--border); background: var(--bg2); padding: 10px; display: flex; flex-direction: column; gap: 8px; box-shadow: 0 -6px 16px rgba(0,0,0,.4); }
|
||||
.cmdline { font-size: 12px; word-break: break-all; white-space: pre-wrap; color: var(--fg); background: #0d1117; padding: 8px; border-radius: 6px; border: 1px solid var(--border); max-height: 120px; overflow: auto; }
|
||||
.cmdline { font-size: 12px; word-break: break-all; white-space: pre-wrap; color: var(--fg); background: var(--term-bg); padding: 8px; border-radius: 6px; border: 1px solid var(--border); max-height: 120px; overflow: auto; }
|
||||
@media (max-width: 800px) {
|
||||
.procs-top { grid-template-columns: 1fr; gap: 14px; padding-top: 16px; }
|
||||
.cmdline { max-height: 72px; }
|
||||
@@ -348,3 +349,31 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
||||
.header .badge.bad { color: var(--bad); border-color: var(--bad); }
|
||||
|
||||
/* settings + themes */
|
||||
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
.theme-card { text-align: left; background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.theme-card:hover { border-color: var(--accent2); } .theme-card.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.theme-name { font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.tprev { border: 1px solid; border-radius: 6px; overflow: hidden; font-size: 9px; height: 96px; display: flex; flex-direction: column; }
|
||||
.tprev-head { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; border-bottom: 1px solid; font-weight: 700; }
|
||||
.tprev-dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.tprev-body { flex: 1; display: flex; }
|
||||
.tprev-rail { width: 34px; border-right: 1px solid; display: flex; flex-direction: column; gap: 4px; padding: 6px 4px; }
|
||||
.tprev-rail span { display: block; height: 7px; border-radius: 2px; background: transparent; }
|
||||
.tprev-main { flex: 1; padding: 6px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.tprev-tile { border: 1px solid; border-radius: 4px; padding: 4px 6px; display: flex; flex-direction: column; gap: 2px; } .tprev-tile b { font-size: 12px; }
|
||||
.tprev-bar { height: 4px; border-radius: 2px; overflow: hidden; } .tprev-bar div { height: 100%; }
|
||||
.tprev-term { flex: 1; border-radius: 4px; padding: 4px 6px; font-family: var(--mono); }
|
||||
|
||||
/* per-theme flourishes */
|
||||
[data-theme="cyberpunk"] .brand, [data-theme="cyberpunk"] .login h1 { text-shadow: 0 0 8px rgba(255, 43, 214, .7), 0 0 18px rgba(255, 43, 214, .35); }
|
||||
[data-theme="cyberpunk"] .gicon, [data-theme="cyberpunk"] .box-title { text-shadow: 0 0 6px rgba(0, 229, 255, .6); }
|
||||
[data-theme="cyberpunk"] .leaf.active, [data-theme="cyberpunk"] .group-head.active { box-shadow: inset 3px 0 0 var(--accent), 0 0 12px rgba(255, 43, 214, .25); }
|
||||
[data-theme="retro"] body, [data-theme="retro"] .btn, [data-theme="retro"] input, [data-theme="retro"] select { font-family: var(--mono); }
|
||||
[data-theme="retro"] .brand, [data-theme="retro"] .login h1 { text-shadow: 0 0 6px rgba(255, 176, 0, .6); letter-spacing: 1px; }
|
||||
[data-theme="retro"] .app::after { content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 100; background: repeating-linear-gradient(0deg, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.12) 2px 3px); }
|
||||
[data-theme="retro"] .tile, [data-theme="retro"] .card, [data-theme="retro"] .box { border-radius: 2px; }
|
||||
[data-theme="light"] .term-overlay { background: rgba(255,255,255,.6); }
|
||||
[data-theme="light"] .toast { box-shadow: 0 4px 16px rgba(0,0,0,.15); }
|
||||
[data-theme="light"] .modal-back { background: rgba(0,0,0,.35); }
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Global themes. Each one is a set of CSS variables applied to <html>, a matching xterm
|
||||
// palette, and a data-theme attribute the stylesheet can hang extra flourishes on.
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
|
||||
export interface Theme {
|
||||
id: string
|
||||
name: string
|
||||
blurb: string
|
||||
vars: Record<string, string>
|
||||
xterm: ITheme
|
||||
}
|
||||
|
||||
const base = {
|
||||
'--ok': '#3fb950', '--bad': '#ff7b72', '--s1': '#3987e5', '--s2': '#c98500', '--track': 'rgba(57, 135, 229, .18)',
|
||||
'--btn-fg': '#111', '--term-bg': '#0d1117', '--font': 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
'--mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
}
|
||||
|
||||
export const THEMES: Theme[] = [
|
||||
{
|
||||
id: 'dark', name: 'Dark', blurb: 'The default. Graphite surfaces, amber brand, blue accents.',
|
||||
vars: { ...base, '--bg': '#111418', '--bg2': '#171b21', '--bg3': '#1e242c', '--border': '#2a313b', '--fg': '#d0d7de', '--muted': '#7d8590', '--accent': '#e3a33b', '--accent2': '#58a6ff' },
|
||||
xterm: {
|
||||
background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78',
|
||||
black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4',
|
||||
brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'light', name: 'Light', blurb: 'Paper white with the same layout. For daylight and bright rooms.',
|
||||
vars: { ...base, '--bg': '#f6f8fa', '--bg2': '#ffffff', '--bg3': '#eaeef2', '--border': '#d0d7de', '--fg': '#1f2328', '--muted': '#59636e', '--accent': '#b35c00', '--accent2': '#0969da',
|
||||
'--ok': '#1a7f37', '--bad': '#cf222e', '--s1': '#2a78d6', '--s2': '#9a6700', '--track': 'rgba(42, 120, 214, .16)', '--btn-fg': '#ffffff', '--term-bg': '#ffffff' },
|
||||
xterm: {
|
||||
background: '#ffffff', foreground: '#1f2328', cursor: '#1f2328', selectionBackground: '#b6d6ff',
|
||||
black: '#24292f', red: '#cf222e', green: '#116329', yellow: '#4d2d00', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#6e7781',
|
||||
brightBlack: '#57606a', brightRed: '#a40e26', brightGreen: '#1a7f37', brightYellow: '#633c01', brightBlue: '#218bff', brightMagenta: '#a475f9', brightCyan: '#3192aa', brightWhite: '#8c959f',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cyberpunk', name: 'Cyberpunk', blurb: 'Deep violet, magenta brand, cyan accents, a little neon glow.',
|
||||
vars: { ...base, '--bg': '#0b0014', '--bg2': '#140026', '--bg3': '#1f0a3a', '--border': '#3b1f6b', '--fg': '#e8e0ff', '--muted': '#9b8bd0', '--accent': '#ff2bd6', '--accent2': '#00e5ff',
|
||||
'--ok': '#39ff14', '--bad': '#ff3860', '--s1': '#00e5ff', '--s2': '#ffe600', '--track': 'rgba(0, 229, 255, .16)', '--btn-fg': '#0b0014', '--term-bg': '#07000f' },
|
||||
xterm: {
|
||||
background: '#07000f', foreground: '#e8e0ff', cursor: '#ff2bd6', selectionBackground: '#3b1f6b',
|
||||
black: '#0b0014', red: '#ff3860', green: '#39ff14', yellow: '#ffe600', blue: '#00e5ff', magenta: '#ff2bd6', cyan: '#5cf6ff', white: '#c8bff0',
|
||||
brightBlack: '#6a55a3', brightRed: '#ff6b8a', brightGreen: '#7dff5c', brightYellow: '#fff27a', brightBlue: '#66eeff', brightMagenta: '#ff7de8', brightCyan: '#a5fbff', brightWhite: '#ffffff',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'retro', name: 'Retro', blurb: 'Amber phosphor terminal. Monospace everywhere, faint scanlines.',
|
||||
vars: { ...base, '--bg': '#0e0b05', '--bg2': '#171106', '--bg3': '#221909', '--border': '#3a2c10', '--fg': '#ffb000', '--muted': '#a8781c', '--accent': '#ffcc33', '--accent2': '#ffb000',
|
||||
'--ok': '#ffd166', '--bad': '#ff6b35', '--s1': '#ffb000', '--s2': '#ff8c00', '--track': 'rgba(255, 176, 0, .16)', '--btn-fg': '#0e0b05', '--term-bg': '#0a0803',
|
||||
'--font': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace' },
|
||||
xterm: {
|
||||
background: '#0a0803', foreground: '#ffb000', cursor: '#ffcc33', selectionBackground: '#3a2c10',
|
||||
black: '#0a0803', red: '#ff6b35', green: '#ffd166', yellow: '#ffcc33', blue: '#e0a020', magenta: '#ff9f43', cyan: '#f5c76b', white: '#ffb000',
|
||||
brightBlack: '#6b4e14', brightRed: '#ff8c5a', brightGreen: '#ffe08a', brightYellow: '#ffe066', brightBlue: '#f0b040', brightMagenta: '#ffb870', brightCyan: '#ffdd99', brightWhite: '#fff3cc',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_THEME = 'dark'
|
||||
const KEY = 'hamlab.theme'
|
||||
|
||||
export function themeById(id: string | null | undefined): Theme {
|
||||
return THEMES.find((t) => t.id === id) ?? THEMES[0]
|
||||
}
|
||||
|
||||
export function currentThemeId(): string {
|
||||
return localStorage.getItem(KEY) || DEFAULT_THEME
|
||||
}
|
||||
|
||||
// applyTheme sets the variables on <html>, remembers the choice, and tells open
|
||||
// terminals to repaint. Cheap enough to call on every load before first render.
|
||||
export function applyTheme(id: string) {
|
||||
const t = themeById(id)
|
||||
const root = document.documentElement
|
||||
for (const [k, v] of Object.entries(t.vars)) root.style.setProperty(k, v)
|
||||
root.dataset.theme = t.id
|
||||
localStorage.setItem(KEY, t.id)
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', t.vars['--bg2'])
|
||||
window.dispatchEvent(new CustomEvent('hamlab:theme', { detail: t }))
|
||||
}
|
||||
Reference in New Issue
Block a user