diff --git a/cmd/server/main.go b/cmd/server/main.go index 994275e..04c9aa8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -41,7 +41,7 @@ func main() { } repo := domain.NewRedisRepository(rdb) - handler := app.NewHandler(repo) + handler := app.NewHandler(repo, cfg.DefaultTheme) secCfg := app.SecurityHeadersConfig{ RequireHTTPS: cfg.RequireHTTPS, diff --git a/internal/app/handler.go b/internal/app/handler.go index 5251b15..7be9e1f 100644 --- a/internal/app/handler.go +++ b/internal/app/handler.go @@ -19,11 +19,12 @@ import ( ) type Handler struct { - repo domain.SecretRepository + repo domain.SecretRepository + defaultTheme string } -func NewHandler(repo domain.SecretRepository) *Handler { - return &Handler{repo: repo} +func NewHandler(repo domain.SecretRepository, defaultTheme string) *Handler { + return &Handler{repo: repo, defaultTheme: defaultTheme} } func (h *Handler) HandleHealth(w http.ResponseWriter, r *http.Request) { @@ -44,6 +45,7 @@ func (h *Handler) HandleConfig(w http.ResponseWriter, r *http.Request) { utility.WriteJSON(w, http.StatusOK, domain.ConfigRes{ MaxSecretSize: domain.MaxSecretSize, ExpiryOptions: domain.ExpiryOptions, + DefaultTheme: h.defaultTheme, }) } diff --git a/internal/app/handler_test.go b/internal/app/handler_test.go index f03ba5a..72fa552 100644 --- a/internal/app/handler_test.go +++ b/internal/app/handler_test.go @@ -76,7 +76,7 @@ func (m *mockSecretRepository) Ping(ctx context.Context) error { func TestHandler_HandleHealth(t *testing.T) { t.Run("returns ok without redis check", func(t *testing.T) { - handler := NewHandler(nil) + handler := NewHandler(nil, "") req := httptest.NewRequest(http.MethodGet, "/health", nil) rr := httptest.NewRecorder() @@ -96,7 +96,7 @@ func TestHandler_HandleHealth(t *testing.T) { return nil }, } - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") req := httptest.NewRequest(http.MethodGet, "/health?redis=true", nil) rr := httptest.NewRecorder() @@ -116,7 +116,7 @@ func TestHandler_HandleHealth(t *testing.T) { return errors.New("connection refused") }, } - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") req := httptest.NewRequest(http.MethodGet, "/health?redis=true", nil) rr := httptest.NewRecorder() @@ -134,7 +134,7 @@ func TestHandler_HandleHealth(t *testing.T) { } func TestHandler_HandleConfig(t *testing.T) { - handler := NewHandler(nil) + handler := NewHandler(nil, "") req := httptest.NewRequest(http.MethodGet, "/config", nil) rr := httptest.NewRecorder() @@ -158,11 +158,43 @@ func TestHandler_HandleConfig(t *testing.T) { } } +func TestHandler_HandleConfig_DefaultTheme(t *testing.T) { + testCases := []struct { + defaultTheme string + wantTheme string + }{ + {"", ""}, + {"light", "light"}, + {"dark", "dark"}, + } + + for _, tc := range testCases { + t.Run("default_theme="+tc.defaultTheme, func(t *testing.T) { + handler := NewHandler(nil, tc.defaultTheme) + req := httptest.NewRequest(http.MethodGet, "/config", nil) + rr := httptest.NewRecorder() + + handler.HandleConfig(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Fatalf("wrong status code: got %v want %v", status, http.StatusOK) + } + var res domain.ConfigRes + if err := json.NewDecoder(rr.Body).Decode(&res); err != nil { + t.Fatalf("could not decode response: %v", err) + } + if res.DefaultTheme != tc.wantTheme { + t.Errorf("wrong default_theme: got %q want %q", res.DefaultTheme, tc.wantTheme) + } + }) + } +} + func TestHandler_HandleCreate(t *testing.T) { utility.LowerCryptoParamsForTest(t) mockRepo := &mockSecretRepository{} - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") t.Run("successful creation", func(t *testing.T) { mockRepo.StoreSecretFunc = func( @@ -282,7 +314,7 @@ func TestHandler_HandleRead(t *testing.T) { utility.LowerCryptoParamsForTest(t) mockRepo := &mockSecretRepository{} - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") secretID := "test-id" passcode, err := utility.GeneratePasscode() if err != nil { @@ -489,7 +521,7 @@ func TestHandler_HandleCreate_ExpiryOptions(t *testing.T) { return nil }, } - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") reqBody := `{"secret":"test","expiry":"` + tc.expiry + `"}` req := httptest.NewRequest( @@ -518,7 +550,7 @@ func TestHandler_HandleCreate_HTTPSDetection(t *testing.T) { return nil }, } - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") t.Run("detects HTTPS from X-Forwarded-Proto header", func(t *testing.T) { reqBody := `{"secret":"test"}` @@ -560,7 +592,7 @@ func TestHandler_HandleCreate_WhitespaceSecret(t *testing.T) { utility.LowerCryptoParamsForTest(t) mockRepo := &mockSecretRepository{} - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") testCases := []struct { name string diff --git a/internal/app/router_test.go b/internal/app/router_test.go index c87df9a..ce9035c 100644 --- a/internal/app/router_test.go +++ b/internal/app/router_test.go @@ -22,7 +22,7 @@ func TestNewRouter_Routes(t *testing.T) { return nil }, } - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") router := NewRouter(handler, nil, SecurityHeadersConfig{}) testCases := []struct { @@ -61,7 +61,7 @@ func TestNewRouter_CreateEndpoint(t *testing.T) { return nil }, } - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") router := NewRouter(handler, nil, SecurityHeadersConfig{}) reqBody := `{"secret":"test-secret"}` @@ -84,7 +84,7 @@ func TestNewRouter_ReadEndpoint_ValidUUID(t *testing.T) { return nil, redis.Nil }, } - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") router := NewRouter(handler, nil, SecurityHeadersConfig{}) // Valid UUID format @@ -105,7 +105,7 @@ func TestNewRouter_ReadEndpoint_InvalidUUID(t *testing.T) { utility.LowerCryptoParamsForTest(t) mockRepo := &mockSecretRepository{} - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") router := NewRouter(handler, nil, SecurityHeadersConfig{}) // Invalid UUID format - should not match route @@ -126,7 +126,7 @@ func TestNewRouter_SecurityHeaders(t *testing.T) { utility.LowerCryptoParamsForTest(t) mockRepo := &mockSecretRepository{} - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") router := NewRouter(handler, nil, SecurityHeadersConfig{}) req := httptest.NewRequest(http.MethodGet, "/health", nil) @@ -147,7 +147,7 @@ func TestNewRouter_RedirectSlashes(t *testing.T) { utility.LowerCryptoParamsForTest(t) mockRepo := &mockSecretRepository{} - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, "") router := NewRouter(handler, nil, SecurityHeadersConfig{}) // Request with trailing slash should redirect diff --git a/internal/config/config.go b/internal/config/config.go index dd732c0..34fb75e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,9 @@ type Config struct { // Security settings RequireHTTPS bool // enforce HTTPS with HSTS header (disable with NO_HTTPS=1) + + // UI settings + DefaultTheme string // "" | "light" | "dark" } // DefaultConfig returns a Config with sensible defaults. @@ -106,6 +109,14 @@ func Load() (Config, error) { cfg.RequireHTTPS = false } + // UI settings + if theme := os.Getenv("DEFAULT_THEME"); theme != "" { + if theme != "light" && theme != "dark" { + return Config{}, fmt.Errorf("DEFAULT_THEME must be 'light' or 'dark', got %q", theme) + } + cfg.DefaultTheme = theme + } + return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d995ee5..762bcb7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -192,6 +192,45 @@ func TestLoad_NoHTTPSDisablesRequireHTTPS(t *testing.T) { } } +func TestLoad_DefaultTheme(t *testing.T) { + t.Run("unset defaults to empty string", func(t *testing.T) { + os.Unsetenv("DEFAULT_THEME") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.DefaultTheme != "" { + t.Errorf("expected empty DefaultTheme, got %q", cfg.DefaultTheme) + } + }) + + for _, theme := range []string{"light", "dark"} { + t.Run(theme, func(t *testing.T) { + os.Setenv("DEFAULT_THEME", theme) + defer os.Unsetenv("DEFAULT_THEME") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.DefaultTheme != theme { + t.Errorf("expected DefaultTheme %q, got %q", theme, cfg.DefaultTheme) + } + }) + } + + t.Run("invalid value returns error", func(t *testing.T) { + os.Setenv("DEFAULT_THEME", "blue") + defer os.Unsetenv("DEFAULT_THEME") + + _, err := Load() + if err == nil { + t.Error("expected error for invalid DEFAULT_THEME") + } + }) +} + func TestLoad_NoHTTPSIgnoresOtherValues(t *testing.T) { testCases := []string{"0", "false", "no", ""} diff --git a/internal/domain/model.go b/internal/domain/model.go index 0949fb2..5213cd6 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -26,4 +26,5 @@ type ReadRes struct { type ConfigRes struct { MaxSecretSize int `json:"max_secret_size"` ExpiryOptions []string `json:"expiry_options"` + DefaultTheme string `json:"default_theme,omitempty"` } diff --git a/web/frontend/App.css b/web/frontend/App.css index 62f8011..e5c1aef 100644 --- a/web/frontend/App.css +++ b/web/frontend/App.css @@ -11,6 +11,22 @@ --error-color: #d93025; --warning-background-color: #ffc; --spacing-unit: 8px; + color-scheme: light; +} + +[data-theme="dark"] { + --primary-color: #e8e8e8; + --secondary-color: #111; + --background-color: #111; + --text-color: #e8e8e8; + --border-color: #2a2a2a; + --light-gray-color: #1e1e1e; + --medium-gray-color: #444; + --dark-gray-color: #888; + --hover-color: #ccc; + --error-color: #f87171; + --warning-background-color: #3a3200; + color-scheme: dark; } html, @@ -41,6 +57,11 @@ select { -webkit-appearance: none; } +::placeholder { + color: var(--dark-gray-color); + opacity: 1; +} + input:focus, textarea:focus, select:focus { diff --git a/web/frontend/App.tsx b/web/frontend/App.tsx index 477b6ee..726f16f 100644 --- a/web/frontend/App.tsx +++ b/web/frontend/App.tsx @@ -4,10 +4,15 @@ import { Create } from './pages/Create'; import { Read } from './pages/Read'; import { About } from './pages/About'; import { Layout } from './components/Layout'; +import { useConfig } from './hooks/useConfig'; +import { useTheme } from './hooks/useTheme'; function App() { + const config = useConfig(); + const { toggleTheme } = useTheme(config.default_theme); + return ( - + diff --git a/web/frontend/components/Layout/Layout.module.css b/web/frontend/components/Layout/Layout.module.css index 885d722..01bf21d 100644 --- a/web/frontend/components/Layout/Layout.module.css +++ b/web/frontend/components/Layout/Layout.module.css @@ -17,9 +17,22 @@ right: 10px; font-family: monospace; font-size: 12px; + display: flex; + align-items: center; } .navLink { text-decoration: underline; color: var(--primary-color); } + +.themeToggle { + background: none; + border: none; + padding: 0; + width: auto; + cursor: pointer; + color: var(--primary-color); + line-height: 0; + margin-left: 6px; +} diff --git a/web/frontend/components/Layout/index.tsx b/web/frontend/components/Layout/index.tsx index 882105d..475100d 100644 --- a/web/frontend/components/Layout/index.tsx +++ b/web/frontend/components/Layout/index.tsx @@ -3,9 +3,10 @@ import styles from './Layout.module.css'; interface LayoutProps { children: ComponentChildren; + onToggleTheme: () => void; } -export function Layout({ children }: LayoutProps) { +export function Layout({ children, onToggleTheme }: LayoutProps) { return (
@@ -17,6 +18,12 @@ export function Layout({ children }: LayoutProps) { about {' | secretapi \u00A9 2025'} +
{children}
diff --git a/web/frontend/hooks/useTheme.ts b/web/frontend/hooks/useTheme.ts new file mode 100644 index 0000000..3824725 --- /dev/null +++ b/web/frontend/hooks/useTheme.ts @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'preact/hooks'; + +export type Theme = 'light' | 'dark'; + +const STORAGE_KEY = 'theme'; + +function applyTheme(theme: Theme) { + document.documentElement.setAttribute('data-theme', theme); +} + +function getInitialTheme(serverDefault?: Theme): Theme { + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + if (stored === 'light' || stored === 'dark') { + return stored; + } + if (serverDefault) { + return serverDefault; + } + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +export function useTheme(serverDefault?: Theme): { theme: Theme; toggleTheme: () => void } { + const [theme, setTheme] = useState(() => { + const initial = getInitialTheme(serverDefault); + applyTheme(initial); + return initial; + }); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored && serverDefault) { + applyTheme(serverDefault); + setTheme(serverDefault); + } + }, [serverDefault]); + + function toggleTheme() { + const next: Theme = theme === 'light' ? 'dark' : 'light'; + applyTheme(next); + localStorage.setItem(STORAGE_KEY, next); + setTheme(next); + } + + return { theme, toggleTheme }; +} diff --git a/web/frontend/types.ts b/web/frontend/types.ts index feeecb3..2aa7b49 100644 --- a/web/frontend/types.ts +++ b/web/frontend/types.ts @@ -16,6 +16,7 @@ export interface ReadResponse { export interface ConfigResponse { max_secret_size: number; expiry_options: string[]; + default_theme?: 'light' | 'dark'; } export type Expiry = string;