Base webui and backend additions
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
.PHONY: service webui test build clean
|
||||
.PHONY: service webui dev test build clean
|
||||
|
||||
export PATH := $(HOME)/.local/go/bin:$(PATH)
|
||||
|
||||
@@ -8,6 +8,13 @@ service:
|
||||
webui:
|
||||
cd webui && npm run dev
|
||||
|
||||
# Run API (:8080) and web UI (:5173) together; Ctrl+C stops both.
|
||||
dev:
|
||||
@trap 'kill 0' EXIT INT TERM; \
|
||||
(cd service && go run ./cmd/server) & \
|
||||
(cd webui && npm run dev) & \
|
||||
wait
|
||||
|
||||
test:
|
||||
cd service && go test ./...
|
||||
cd webui && npm test
|
||||
|
||||
@@ -14,18 +14,49 @@ A web-based dashboard for managing Proxmox VMs and LXC containers. After configu
|
||||
- Go 1.22+
|
||||
- Node.js 20+ and npm
|
||||
|
||||
## Run
|
||||
## Configuration
|
||||
|
||||
Start the API (default `:8080`, override with `PORT`):
|
||||
Service config lives under **`/etc/ClusterCanvas/`** by default:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `settings.json` | Plain base settings (non-secret) |
|
||||
| `secrets.enc` | AES-256-GCM encrypted JSON secrets |
|
||||
|
||||
**Config directory precedence** (highest first):
|
||||
|
||||
1. `-configdir` flag — e.g. `./bin/clustercanvas-server -configdir="~/.myconfigs"`
|
||||
2. `CLUSTERCANVAS_CONFIG_DIR` environment variable
|
||||
3. `/etc/ClusterCanvas`
|
||||
|
||||
A leading `~/` in the path is expanded to the user home directory.
|
||||
|
||||
**Secrets encryption key:** set `CLUSTERCANVAS_CONFIG_KEY` to a base64-encoded 32-byte key:
|
||||
|
||||
```bash
|
||||
make service
|
||||
openssl rand -base64 32
|
||||
export CLUSTERCANVAS_CONFIG_KEY='…paste output…'
|
||||
```
|
||||
|
||||
Start the web UI (Vite on `:5173`, proxies `/health` and `/api` to the service):
|
||||
## Run
|
||||
|
||||
Start API and web UI together (API `:8080`, UI `:5173`; Ctrl+C stops both):
|
||||
|
||||
```bash
|
||||
make webui
|
||||
make dev
|
||||
```
|
||||
|
||||
Or start them separately:
|
||||
|
||||
```bash
|
||||
make service # API on :8080 (override with PORT)
|
||||
make webui # UI on :5173 (proxies /health and /api)
|
||||
```
|
||||
|
||||
Custom config directory for the API:
|
||||
|
||||
```bash
|
||||
cd service && go run ./cmd/server -configdir="$HOME/.myconfigs"
|
||||
```
|
||||
|
||||
Optional: set `VITE_API_BASE_URL` (defaults to `http://localhost:8080`) if you call the API without the Vite proxy.
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -11,9 +12,23 @@ import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/api"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configDirFlag := flag.String(
|
||||
"configdir",
|
||||
"",
|
||||
"config directory (default /etc/ClusterCanvas; overrides CLUSTERCANVAS_CONFIG_DIR)",
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
configDir, err := settings.ResolveDir(*configDirFlag)
|
||||
if err != nil {
|
||||
log.Fatalf("config dir: %v", err)
|
||||
}
|
||||
log.Printf("config directory: %s", configDir)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const Version = "0.1.0"
|
||||
const Version = "0.0.1"
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func seal(plaintext []byte, key []byte) (nonce []byte, ciphertext []byte, err error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("aes cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("gcm: %w", err)
|
||||
}
|
||||
|
||||
nonce = make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, nil, fmt.Errorf("nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext = gcm.Seal(nil, nonce, plaintext, nil)
|
||||
return nonce, ciphertext, nil
|
||||
}
|
||||
|
||||
func open(nonce []byte, ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcm: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func decodeKey(base64Key string) ([]byte, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(base64Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode key: %w", err)
|
||||
}
|
||||
if length := len(key); length != 16 && length != 24 && length != 32 {
|
||||
return nil, fmt.Errorf("key must be 16, 24, or 32 bytes after base64 decode, got %d", length)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultConfigDir = "/etc/ClusterCanvas"
|
||||
ConfigDirEnvVar = "CLUSTERCANVAS_CONFIG_DIR"
|
||||
ConfigKeyEnvVar = "CLUSTERCANVAS_CONFIG_KEY"
|
||||
|
||||
SettingsFileName = "settings.json"
|
||||
SecretsFileName = "secrets.enc"
|
||||
)
|
||||
|
||||
// ResolveDir returns the config directory using precedence:
|
||||
// 1. flagValue (-configdir), if non-empty
|
||||
// 2. CLUSTERCANVAS_CONFIG_DIR, if set
|
||||
// 3. /etc/ClusterCanvas
|
||||
//
|
||||
// Leading ~/ is expanded to the user home directory.
|
||||
func ResolveDir(flagValue string) (string, error) {
|
||||
candidate := strings.TrimSpace(flagValue)
|
||||
if candidate == "" {
|
||||
candidate = strings.TrimSpace(os.Getenv(ConfigDirEnvVar))
|
||||
}
|
||||
if candidate == "" {
|
||||
candidate = DefaultConfigDir
|
||||
}
|
||||
|
||||
expanded, err := expandHome(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(expanded), nil
|
||||
}
|
||||
|
||||
func expandHome(path string) (string, error) {
|
||||
if path == "~" {
|
||||
homeDirectory, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("expand home directory: %w", err)
|
||||
}
|
||||
return homeDirectory, nil
|
||||
}
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
homeDirectory, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("expand home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDirectory, path[2:]), nil
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ensureConfigDir(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create config dir %q: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type encryptedBlob struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
// KeyFromEnv reads and decodes CLUSTERCANVAS_CONFIG_KEY (base64, 16/24/32 bytes).
|
||||
func KeyFromEnv() ([]byte, error) {
|
||||
rawKey := os.Getenv(ConfigKeyEnvVar)
|
||||
if rawKey == "" {
|
||||
return nil, fmt.Errorf("%s is not set", ConfigKeyEnvVar)
|
||||
}
|
||||
return decodeKey(rawKey)
|
||||
}
|
||||
|
||||
// LoadSecrets decrypts secrets.enc from dir using key.
|
||||
func LoadSecrets(dir string, key []byte) (Secrets, error) {
|
||||
path := filepath.Join(dir, SecretsFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Secrets{}, fmt.Errorf("read secrets: %w", err)
|
||||
}
|
||||
|
||||
var blob encryptedBlob
|
||||
if err := json.Unmarshal(payload, &blob); err != nil {
|
||||
return Secrets{}, fmt.Errorf("parse secrets envelope: %w", err)
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
|
||||
if err != nil {
|
||||
return Secrets{}, fmt.Errorf("decode nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
|
||||
if err != nil {
|
||||
return Secrets{}, fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := open(nonce, ciphertext, key)
|
||||
if err != nil {
|
||||
return Secrets{}, err
|
||||
}
|
||||
|
||||
var secrets Secrets
|
||||
if err := json.Unmarshal(plaintext, &secrets); err != nil {
|
||||
return Secrets{}, fmt.Errorf("parse secrets: %w", err)
|
||||
}
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// SaveSecrets encrypts secrets and writes secrets.enc to dir with mode 0600.
|
||||
func SaveSecrets(dir string, secrets Secrets, key []byte) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plaintext, err := json.Marshal(secrets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode secrets: %w", err)
|
||||
}
|
||||
|
||||
nonce, ciphertext, err := seal(plaintext, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blob := encryptedBlob{
|
||||
Nonce: base64.StdEncoding.EncodeToString(nonce),
|
||||
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
||||
}
|
||||
payload, err := json.MarshalIndent(blob, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode secrets envelope: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, SecretsFileName)
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
return fmt.Errorf("write secrets: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LoadSettings reads and parses settings.json from dir.
|
||||
func LoadSettings(dir string) (Settings, error) {
|
||||
path := filepath.Join(dir, SettingsFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("read settings: %w", err)
|
||||
}
|
||||
|
||||
var settings Settings
|
||||
if err := json.Unmarshal(payload, &settings); err != nil {
|
||||
return Settings{}, fmt.Errorf("parse settings: %w", err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// SaveSettings writes settings.json to dir with mode 0640.
|
||||
func SaveSettings(dir string, settings Settings) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode settings: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, SettingsFileName)
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDirDefault(t *testing.T) {
|
||||
t.Setenv(ConfigDirEnvVar, "")
|
||||
|
||||
resolved, err := ResolveDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDir: %v", err)
|
||||
}
|
||||
if resolved != DefaultConfigDir {
|
||||
t.Fatalf("got %q, want %q", resolved, DefaultConfigDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirFlagOverridesEnv(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
flagDir := filepath.Join(tempDir, "from-flag")
|
||||
envDir := filepath.Join(tempDir, "from-env")
|
||||
t.Setenv(ConfigDirEnvVar, envDir)
|
||||
|
||||
resolved, err := ResolveDir(flagDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDir: %v", err)
|
||||
}
|
||||
if resolved != flagDir {
|
||||
t.Fatalf("got %q, want flag dir %q", resolved, flagDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirEnvOverridesDefault(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
envDir := filepath.Join(tempDir, "from-env")
|
||||
t.Setenv(ConfigDirEnvVar, envDir)
|
||||
|
||||
resolved, err := ResolveDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDir: %v", err)
|
||||
}
|
||||
if resolved != envDir {
|
||||
t.Fatalf("got %q, want env dir %q", resolved, envDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirExpandsTilde(t *testing.T) {
|
||||
homeDirectory, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserHomeDir: %v", err)
|
||||
}
|
||||
t.Setenv(ConfigDirEnvVar, "")
|
||||
|
||||
resolved, err := ResolveDir("~/myconfigs")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDir: %v", err)
|
||||
}
|
||||
want := filepath.Join(homeDirectory, "myconfigs")
|
||||
if resolved != want {
|
||||
t.Fatalf("got %q, want %q", resolved, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
original := Settings{LogLevel: "debug"}
|
||||
|
||||
if err := SaveSettings(dir, original); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSettings(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
if loaded.LogLevel != original.LogLevel {
|
||||
t.Fatalf("got log_level %q, want %q", loaded.LogLevel, original.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func testKey(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func TestSecretsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key := testKey(t)
|
||||
original := Secrets{}
|
||||
|
||||
if err := SaveSecrets(dir, original, key); err != nil {
|
||||
t.Fatalf("SaveSecrets: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSecrets(dir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecrets: %v", err)
|
||||
}
|
||||
_ = loaded
|
||||
}
|
||||
|
||||
func TestSecretsWrongKeyFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
goodKey := testKey(t)
|
||||
if err := SaveSecrets(dir, Secrets{}, goodKey); err != nil {
|
||||
t.Fatalf("SaveSecrets: %v", err)
|
||||
}
|
||||
|
||||
badKey := make([]byte, 32)
|
||||
for index := range badKey {
|
||||
badKey[index] = 0xff
|
||||
}
|
||||
|
||||
if _, err := LoadSecrets(dir, badKey); err == nil {
|
||||
t.Fatal("expected decrypt failure with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFromEnvMissing(t *testing.T) {
|
||||
t.Setenv(ConfigKeyEnvVar, "")
|
||||
if _, err := KeyFromEnv(); err == nil {
|
||||
t.Fatal("expected error when key env is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFromEnvValid(t *testing.T) {
|
||||
key := testKey(t)
|
||||
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(key))
|
||||
|
||||
decoded, err := KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
if len(decoded) != 32 {
|
||||
t.Fatalf("got key length %d, want 32", len(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFromEnvInvalidLength(t *testing.T) {
|
||||
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString([]byte("short")))
|
||||
if _, err := KeyFromEnv(); err == nil {
|
||||
t.Fatal("expected error for invalid key length")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package settings
|
||||
|
||||
// Settings holds plain (non-secret) base/CLI configuration.
|
||||
type Settings struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
}
|
||||
|
||||
// Secrets holds sensitive configuration encrypted at rest.
|
||||
type Secrets struct {
|
||||
// Placeholder until Proxmox/API secrets exist.
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+168
-9
@@ -1,7 +1,7 @@
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
color: #12263a;
|
||||
color: var(--ctp-text);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
flex-direction: column;
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: #f7fafc;
|
||||
border-right: 1px solid #c5d4e0;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
border-bottom: 1px solid #c5d4e0;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -25,13 +28,14 @@
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.version-line {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: #3a5166;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.version-line + .version-line {
|
||||
@@ -49,7 +53,7 @@
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid #c5d4e0;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@@ -67,14 +71,21 @@
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #e4eef5;
|
||||
background: var(--ctp-mauve-hover);
|
||||
}
|
||||
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
background: #d7e7f2;
|
||||
background: var(--ctp-mauve-muted);
|
||||
color: var(--ctp-mauve);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -96,12 +107,160 @@
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
color: #3a5166;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem 0.25rem;
|
||||
border-bottom: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.config-tab {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
background: transparent;
|
||||
color: var(--ctp-subtext1);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-tab:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.config-tab-active {
|
||||
color: var(--ctp-mauve);
|
||||
border-bottom-color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.config-tab:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.config-tab-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.config-field label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
max-width: 16rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-select:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.config-empty {
|
||||
margin: 0;
|
||||
max-width: 36rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.config-action-revert,
|
||||
.config-action-save {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-action-revert {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
background: transparent;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.config-action-revert:hover:not(:disabled) {
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.config-action-save {
|
||||
border: 1px solid var(--ctp-mauve);
|
||||
background: var(--ctp-mauve);
|
||||
color: var(--ctp-base);
|
||||
}
|
||||
|
||||
.config-action-save:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.config-action-revert:disabled,
|
||||
.config-action-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.config-action-revert:focus-visible,
|
||||
.config-action-save:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
+165
-22
@@ -1,32 +1,63 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from './App'
|
||||
import { THEME_STORAGE_KEY } from './theme'
|
||||
|
||||
function stubFetchOk() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
service: 'clustercanvas',
|
||||
version: '0.1.0',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function stubMatchMedia(prefersDark: boolean) {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('dark') ? prefersDark : !prefersDark,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
stubMatchMedia(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
delete document.documentElement.dataset.theme
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders the dashboard shell with brand and nav', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
service: 'clustercanvas',
|
||||
version: '0.1.0',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
stubFetchOk()
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Cluster Canvas' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`),
|
||||
).toBeInTheDocument()
|
||||
expect(await screen.findByText('Backend Version 0.1.0')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument()
|
||||
@@ -46,16 +77,7 @@ describe('App', () => {
|
||||
|
||||
it('switches the content panel when a nav item is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
service: 'clustercanvas',
|
||||
version: '0.1.0',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
stubFetchOk()
|
||||
|
||||
render(<App />)
|
||||
|
||||
@@ -68,6 +90,49 @@ describe('App', () => {
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByLabelText('Theme')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches Configuration tabs and shows empty states', async () => {
|
||||
const user = userEvent.setup()
|
||||
stubFetchOk()
|
||||
|
||||
render(<App />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
|
||||
for (const label of [
|
||||
'Overview',
|
||||
'Users',
|
||||
'Groups',
|
||||
'Security',
|
||||
'Integrations',
|
||||
'Network',
|
||||
'Advanced',
|
||||
]) {
|
||||
expect(screen.getByRole('tab', { name: label })).toBeInTheDocument()
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Security' }))
|
||||
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(
|
||||
screen.getByText(/Service SSH keys and related credentials/i),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Integrations' }))
|
||||
expect(screen.getByRole('tab', { name: 'Integrations' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByText('Coming soon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unavailable when backend status fetch fails', async () => {
|
||||
@@ -85,4 +150,82 @@ describe('App', () => {
|
||||
await screen.findByText('Backend Version unavailable'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to latte when preference is auto and browser is light', () => {
|
||||
stubFetchOk()
|
||||
stubMatchMedia(false)
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe('latte')
|
||||
})
|
||||
|
||||
it('does not apply theme until Save is pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
stubFetchOk()
|
||||
stubMatchMedia(false)
|
||||
|
||||
render(<App />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Theme'), 'dark')
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe('latte')
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Revert' })).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('reverts draft theme changes without applying them', async () => {
|
||||
const user = userEvent.setup()
|
||||
stubFetchOk()
|
||||
stubMatchMedia(true)
|
||||
localStorage.setItem(THEME_STORAGE_KEY, 'dark')
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.selectOptions(screen.getByLabelText('Theme'), 'light')
|
||||
|
||||
expect(screen.getByLabelText('Theme')).toHaveValue('light')
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Revert' }))
|
||||
|
||||
expect(screen.getByLabelText('Theme')).toHaveValue('dark')
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Revert' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('applies latte when the user saves Light in Configuration', async () => {
|
||||
const user = userEvent.setup()
|
||||
stubFetchOk()
|
||||
stubMatchMedia(true)
|
||||
localStorage.setItem(THEME_STORAGE_KEY, 'dark')
|
||||
|
||||
render(<App />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.selectOptions(screen.getByLabelText('Theme'), 'light')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe('latte')
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light')
|
||||
})
|
||||
})
|
||||
|
||||
+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>
|
||||
)
|
||||
|
||||
+48
-3
@@ -1,13 +1,57 @@
|
||||
/* Catppuccin Latte (light) — default until JS sets data-theme */
|
||||
:root,
|
||||
[data-theme='latte'] {
|
||||
--ctp-base: #eff1f5;
|
||||
--ctp-mantle: #e6e9ef;
|
||||
--ctp-crust: #dce0e8;
|
||||
--ctp-surface0: #ccd0da;
|
||||
--ctp-surface1: #bcc0cc;
|
||||
--ctp-overlay0: #9ca0b0;
|
||||
--ctp-overlay1: #8c8fa1;
|
||||
--ctp-text: #4c4f69;
|
||||
--ctp-subtext0: #6c6f85;
|
||||
--ctp-subtext1: #5c5f77;
|
||||
--ctp-mauve: #8839ef;
|
||||
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
|
||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Catppuccin Mocha (dark) */
|
||||
[data-theme='mocha'] {
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
|
||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root {
|
||||
color: #12263a;
|
||||
color: var(--ctp-text);
|
||||
background:
|
||||
radial-gradient(circle at top left, #d7e7f2 0%, transparent 45%),
|
||||
linear-gradient(180deg, #f4f8fb 0%, #e8eef3 100%);
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
color-mix(in srgb, var(--ctp-mauve) 22%, transparent) 0%,
|
||||
transparent 45%
|
||||
),
|
||||
linear-gradient(180deg, var(--ctp-base) 0%, var(--ctp-mantle) 100%);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -17,6 +61,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { applyThemeFromPreference } from './theme.ts'
|
||||
|
||||
applyThemeFromPreference()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
THEME_STORAGE_KEY,
|
||||
applyResolvedTheme,
|
||||
applyThemeFromPreference,
|
||||
isThemePreference,
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
writeThemePreference,
|
||||
} from './theme'
|
||||
|
||||
function createMemoryStorage(initial: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(initial))
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('isThemePreference', () => {
|
||||
it('accepts light, dark, and auto', () => {
|
||||
expect(isThemePreference('light')).toBe(true)
|
||||
expect(isThemePreference('dark')).toBe(true)
|
||||
expect(isThemePreference('auto')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid values', () => {
|
||||
expect(isThemePreference('mocha')).toBe(false)
|
||||
expect(isThemePreference('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readThemePreference', () => {
|
||||
it('defaults to auto when nothing is stored', () => {
|
||||
expect(readThemePreference(createMemoryStorage())).toBe('auto')
|
||||
})
|
||||
|
||||
it('returns a stored preference', () => {
|
||||
const storage = createMemoryStorage({ [THEME_STORAGE_KEY]: 'dark' })
|
||||
expect(readThemePreference(storage)).toBe('dark')
|
||||
})
|
||||
|
||||
it('falls back to auto for invalid stored values', () => {
|
||||
const storage = createMemoryStorage({ [THEME_STORAGE_KEY]: 'neon' })
|
||||
expect(readThemePreference(storage)).toBe('auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeThemePreference', () => {
|
||||
it('persists the preference', () => {
|
||||
const storage = createMemoryStorage()
|
||||
writeThemePreference('light', storage)
|
||||
expect(storage.getItem(THEME_STORAGE_KEY)).toBe('light')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTheme', () => {
|
||||
it('maps light to latte and dark to mocha', () => {
|
||||
expect(resolveTheme('light', true)).toBe('latte')
|
||||
expect(resolveTheme('dark', false)).toBe('mocha')
|
||||
})
|
||||
|
||||
it('maps auto from the browser preference', () => {
|
||||
expect(resolveTheme('auto', true)).toBe('mocha')
|
||||
expect(resolveTheme('auto', false)).toBe('latte')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyResolvedTheme', () => {
|
||||
afterEach(() => {
|
||||
delete document.documentElement.dataset.theme
|
||||
})
|
||||
|
||||
it('sets data-theme on the root element', () => {
|
||||
applyResolvedTheme('mocha')
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyThemeFromPreference', () => {
|
||||
afterEach(() => {
|
||||
delete document.documentElement.dataset.theme
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('applies the resolved theme from preference and system preference', () => {
|
||||
const resolved = applyThemeFromPreference('auto', true)
|
||||
expect(resolved).toBe('mocha')
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
export type ThemePreference = 'light' | 'dark' | 'auto'
|
||||
export type ResolvedTheme = 'latte' | 'mocha'
|
||||
|
||||
export const THEME_STORAGE_KEY = 'clustercanvas.theme'
|
||||
|
||||
const VALID_PREFERENCES: ReadonlySet<string> = new Set([
|
||||
'light',
|
||||
'dark',
|
||||
'auto',
|
||||
])
|
||||
|
||||
export function isThemePreference(value: string): value is ThemePreference {
|
||||
return VALID_PREFERENCES.has(value)
|
||||
}
|
||||
|
||||
export function readThemePreference(
|
||||
storage: Pick<Storage, 'getItem'> = localStorage,
|
||||
): ThemePreference {
|
||||
const storedValue = storage.getItem(THEME_STORAGE_KEY)
|
||||
if (storedValue !== null && isThemePreference(storedValue)) {
|
||||
return storedValue
|
||||
}
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
export function writeThemePreference(
|
||||
preference: ThemePreference,
|
||||
storage: Pick<Storage, 'setItem'> = localStorage,
|
||||
): void {
|
||||
storage.setItem(THEME_STORAGE_KEY, preference)
|
||||
}
|
||||
|
||||
export function resolveTheme(
|
||||
preference: ThemePreference,
|
||||
prefersDark: boolean,
|
||||
): ResolvedTheme {
|
||||
if (preference === 'light') {
|
||||
return 'latte'
|
||||
}
|
||||
if (preference === 'dark') {
|
||||
return 'mocha'
|
||||
}
|
||||
return prefersDark ? 'mocha' : 'latte'
|
||||
}
|
||||
|
||||
export function getSystemPrefersDark(
|
||||
mediaQueryList: Pick<MediaQueryList, 'matches'> = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)',
|
||||
),
|
||||
): boolean {
|
||||
return mediaQueryList.matches
|
||||
}
|
||||
|
||||
export function applyResolvedTheme(
|
||||
resolvedTheme: ResolvedTheme,
|
||||
rootElement: HTMLElement = document.documentElement,
|
||||
): void {
|
||||
rootElement.dataset.theme = resolvedTheme
|
||||
}
|
||||
|
||||
export function applyThemeFromPreference(
|
||||
preference: ThemePreference = readThemePreference(),
|
||||
prefersDark: boolean = getSystemPrefersDark(),
|
||||
rootElement: HTMLElement = document.documentElement,
|
||||
): ResolvedTheme {
|
||||
const resolvedTheme = resolveTheme(preference, prefersDark)
|
||||
applyResolvedTheme(resolvedTheme, rootElement)
|
||||
return resolvedTheme
|
||||
}
|
||||
Reference in New Issue
Block a user