From 7abcbafaf4105eb955ded594553f5b6a5f35c0cb Mon Sep 17 00:00:00 2001 From: woikos Date: Fri, 16 Jan 2026 17:07:00 +0100 Subject: [PATCH] feat(branding): add white-label branding system (v0.52.0) Add runtime-customizable branding allowing relay operators to fully customize UI appearance without rebuilding: - Custom logo, favicon, and PWA icons - Full CSS override capability (colors, themes, components) - Custom app name, title, and NIP-11 relay info - init-branding command with --style generic|orly options - Transparent PNG generation for generic branding New files: - app/branding/ package (branding.go, init.go, types.go) - docs/BRANDING_GUIDE.md Environment variables: - ORLY_BRANDING_DIR: branding directory path - ORLY_BRANDING_ENABLED: enable/disable custom branding Usage: ./orly init-branding --style generic Co-Authored-By: Claude Opus 4.5 --- app/branding/branding.go | 341 +++++++++++++++++ app/branding/init.go | 790 +++++++++++++++++++++++++++++++++++++++ app/branding/types.go | 81 ++++ app/config/config.go | 43 ++- app/handle-relayinfo.go | 14 + app/main.go | 17 + app/server.go | 167 +++++++++ app/web.go | 6 + docs/BRANDING_GUIDE.md | 246 ++++++++++++ main.go | 37 ++ 10 files changed, 1740 insertions(+), 2 deletions(-) create mode 100644 app/branding/branding.go create mode 100644 app/branding/init.go create mode 100644 app/branding/types.go create mode 100644 docs/BRANDING_GUIDE.md diff --git a/app/branding/branding.go b/app/branding/branding.go new file mode 100644 index 0000000..a4590e3 --- /dev/null +++ b/app/branding/branding.go @@ -0,0 +1,341 @@ +package branding + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "mime" + "os" + "path/filepath" + "regexp" + "strings" + + "lol.mleku.dev/chk" + "lol.mleku.dev/log" +) + +// Manager handles loading and serving custom branding assets +type Manager struct { + dir string + config Config + + // Cached assets for performance + cachedAssets map[string][]byte + cachedCSS []byte +} + +// New creates a new branding Manager by loading configuration from the specified directory +func New(dir string) (*Manager, error) { + m := &Manager{ + dir: dir, + cachedAssets: make(map[string][]byte), + } + + // Load branding.json + configPath := filepath.Join(dir, "branding.json") + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + log.I.F("branding.json not found in %s, using defaults", dir) + m.config = DefaultConfig() + } else { + return nil, fmt.Errorf("failed to read branding.json: %w", err) + } + } else { + if err := json.Unmarshal(data, &m.config); err != nil { + return nil, fmt.Errorf("failed to parse branding.json: %w", err) + } + } + + // Pre-load and cache CSS + if err := m.loadCSS(); err != nil { + log.W.F("failed to load custom CSS: %v", err) + } + + return m, nil +} + +// Dir returns the branding directory path +func (m *Manager) Dir() string { + return m.dir +} + +// Config returns the loaded branding configuration +func (m *Manager) Config() Config { + return m.config +} + +// GetAsset returns a custom asset by name with its MIME type +// Returns the asset data, MIME type, and whether it was found +func (m *Manager) GetAsset(name string) ([]byte, string, bool) { + var assetPath string + + switch name { + case "logo": + assetPath = m.config.Assets.Logo + case "favicon": + assetPath = m.config.Assets.Favicon + case "icon-192": + assetPath = m.config.Assets.Icon192 + case "icon-512": + assetPath = m.config.Assets.Icon512 + default: + return nil, "", false + } + + if assetPath == "" { + return nil, "", false + } + + // Check cache first + if data, ok := m.cachedAssets[name]; ok { + return data, m.getMimeType(assetPath), true + } + + // Load from disk + fullPath := filepath.Join(m.dir, assetPath) + data, err := os.ReadFile(fullPath) + if chk.D(err) { + return nil, "", false + } + + // Cache for next time + m.cachedAssets[name] = data + return data, m.getMimeType(assetPath), true +} + +// GetAssetPath returns the full filesystem path for a custom asset +func (m *Manager) GetAssetPath(name string) (string, bool) { + var assetPath string + + switch name { + case "logo": + assetPath = m.config.Assets.Logo + case "favicon": + assetPath = m.config.Assets.Favicon + case "icon-192": + assetPath = m.config.Assets.Icon192 + case "icon-512": + assetPath = m.config.Assets.Icon512 + default: + return "", false + } + + if assetPath == "" { + return "", false + } + + fullPath := filepath.Join(m.dir, assetPath) + if _, err := os.Stat(fullPath); err != nil { + return "", false + } + + return fullPath, true +} + +// loadCSS loads and caches the custom CSS files +func (m *Manager) loadCSS() error { + var combined bytes.Buffer + + // Load variables CSS first (if exists) + if m.config.CSS.VariablesCSS != "" { + varsPath := filepath.Join(m.dir, m.config.CSS.VariablesCSS) + if data, err := os.ReadFile(varsPath); err == nil { + combined.Write(data) + combined.WriteString("\n") + } + } + + // Load custom CSS (if exists) + if m.config.CSS.CustomCSS != "" { + customPath := filepath.Join(m.dir, m.config.CSS.CustomCSS) + if data, err := os.ReadFile(customPath); err == nil { + combined.Write(data) + } + } + + if combined.Len() > 0 { + m.cachedCSS = combined.Bytes() + } + + return nil +} + +// GetCustomCSS returns the combined custom CSS content +func (m *Manager) GetCustomCSS() ([]byte, error) { + if m.cachedCSS == nil { + return nil, fs.ErrNotExist + } + return m.cachedCSS, nil +} + +// HasCustomCSS returns true if custom CSS is available +func (m *Manager) HasCustomCSS() bool { + return len(m.cachedCSS) > 0 +} + +// GetManifest generates a customized manifest.json +func (m *Manager) GetManifest(originalManifest []byte) ([]byte, error) { + var manifest map[string]any + + if err := json.Unmarshal(originalManifest, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse original manifest: %w", err) + } + + // Apply customizations + if m.config.App.Name != "" { + manifest["name"] = m.config.App.Name + } + if m.config.App.ShortName != "" { + manifest["short_name"] = m.config.App.ShortName + } + if m.config.App.Description != "" { + manifest["description"] = m.config.App.Description + } + if m.config.Manifest.ThemeColor != "" { + manifest["theme_color"] = m.config.Manifest.ThemeColor + } + if m.config.Manifest.BackgroundColor != "" { + manifest["background_color"] = m.config.Manifest.BackgroundColor + } + + // Update icon paths to use branding endpoints + if icons, ok := manifest["icons"].([]any); ok { + for i, icon := range icons { + if iconMap, ok := icon.(map[string]any); ok { + if src, ok := iconMap["src"].(string); ok { + // Replace icon paths with branding paths + if strings.Contains(src, "192") { + iconMap["src"] = "/branding/icon-192.png" + } else if strings.Contains(src, "512") { + iconMap["src"] = "/branding/icon-512.png" + } + icons[i] = iconMap + } + } + } + manifest["icons"] = icons + } + + return json.MarshalIndent(manifest, "", " ") +} + +// ModifyIndexHTML modifies the index.html to inject custom branding +func (m *Manager) ModifyIndexHTML(original []byte) ([]byte, error) { + html := string(original) + + // Inject custom CSS link before + if m.HasCustomCSS() { + cssLink := `` + html = strings.Replace(html, "", cssLink+"\n", 1) + } + + // Inject JavaScript to change header text at runtime + if m.config.App.Name != "" { + // This script runs after DOM is loaded and updates the header text + brandingScript := fmt.Sprintf(``, m.config.App.Name+" dashboard") + html = strings.Replace(html, "", brandingScript+"\n", 1) + } + + // Replace title if custom title is set + if m.config.App.Title != "" { + titleRegex := regexp.MustCompile(`[^<]*`) + html = titleRegex.ReplaceAllString(html, fmt.Sprintf("%s", m.config.App.Title)) + } + + // Replace logo path to use branding endpoint + if m.config.Assets.Logo != "" { + // Replace orly.png references with branding logo endpoint + html = strings.ReplaceAll(html, `"/orly.png"`, `"/branding/logo.png"`) + html = strings.ReplaceAll(html, `'/orly.png'`, `'/branding/logo.png'`) + html = strings.ReplaceAll(html, `src="/orly.png"`, `src="/branding/logo.png"`) + } + + // Replace favicon path + if m.config.Assets.Favicon != "" { + html = strings.ReplaceAll(html, `href="/favicon.png"`, `href="/branding/favicon.png"`) + html = strings.ReplaceAll(html, `href="favicon.png"`, `href="/branding/favicon.png"`) + } + + // Replace manifest path to use dynamic endpoint + html = strings.ReplaceAll(html, `href="/manifest.json"`, `href="/branding/manifest.json"`) + html = strings.ReplaceAll(html, `href="manifest.json"`, `href="/branding/manifest.json"`) + + return []byte(html), nil +} + +// NIP11Config returns the NIP-11 branding configuration +func (m *Manager) NIP11Config() NIP11Config { + return m.config.NIP11 +} + +// AppName returns the custom app name, or empty string if not set +func (m *Manager) AppName() string { + return m.config.App.Name +} + +// getMimeType determines the MIME type from a file path +func (m *Manager) getMimeType(path string) string { + ext := filepath.Ext(path) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + // Default fallbacks + switch strings.ToLower(ext) { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".svg": + return "image/svg+xml" + case ".ico": + return "image/x-icon" + case ".css": + return "text/css" + case ".js": + return "application/javascript" + default: + return "application/octet-stream" + } + } + return mimeType +} + +// ClearCache clears all cached assets (useful for hot-reload during development) +func (m *Manager) ClearCache() { + m.cachedAssets = make(map[string][]byte) + m.cachedCSS = nil + _ = m.loadCSS() +} diff --git a/app/branding/init.go b/app/branding/init.go new file mode 100644 index 0000000..2600a6d --- /dev/null +++ b/app/branding/init.go @@ -0,0 +1,790 @@ +package branding + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "io/fs" + "math" + "os" + "path/filepath" +) + +// BrandingStyle represents the type of branding kit to generate +type BrandingStyle string + +const ( + StyleORLY BrandingStyle = "orly" // ORLY-branded assets + StyleGeneric BrandingStyle = "generic" // Generic/white-label assets +) + +// InitBrandingKit creates a branding directory with assets and configuration +func InitBrandingKit(dir string, embeddedFS embed.FS, style BrandingStyle) error { + // Create directory structure + dirs := []string{ + dir, + filepath.Join(dir, "assets"), + filepath.Join(dir, "css"), + } + + for _, d := range dirs { + if err := os.MkdirAll(d, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", d, err) + } + } + + // Write branding.json based on style + var config Config + var cssTemplate, varsTemplate string + + switch style { + case StyleGeneric: + config = GenericConfig() + cssTemplate = GenericCSSTemplate + varsTemplate = GenericCSSVariablesTemplate + default: + config = DefaultConfig() + cssTemplate = CSSTemplate + varsTemplate = CSSVariablesTemplate + } + + configData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + configPath := filepath.Join(dir, "branding.json") + if err := os.WriteFile(configPath, configData, 0644); err != nil { + return fmt.Errorf("failed to write branding.json: %w", err) + } + + // Generate or extract assets based on style + if style == StyleGeneric { + // Generate generic placeholder images + if err := generateGenericAssets(dir); err != nil { + return fmt.Errorf("failed to generate generic assets: %w", err) + } + } else { + // Extract ORLY embedded assets + assetMappings := map[string]string{ + "web/dist/orly.png": filepath.Join(dir, "assets", "logo.png"), + "web/dist/favicon.png": filepath.Join(dir, "assets", "favicon.png"), + "web/dist/icon-192.png": filepath.Join(dir, "assets", "icon-192.png"), + "web/dist/icon-512.png": filepath.Join(dir, "assets", "icon-512.png"), + } + + for src, dst := range assetMappings { + data, err := fs.ReadFile(embeddedFS, src) + if err != nil { + altSrc := "web/" + filepath.Base(src) + data, err = fs.ReadFile(embeddedFS, altSrc) + if err != nil { + fmt.Printf("Warning: could not extract %s: %v\n", src, err) + continue + } + } + if err := os.WriteFile(dst, data, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", dst, err) + } + } + } + + // Write CSS template + cssPath := filepath.Join(dir, "css", "custom.css") + if err := os.WriteFile(cssPath, []byte(cssTemplate), 0644); err != nil { + return fmt.Errorf("failed to write custom.css: %w", err) + } + + // Write variables-only CSS template + varsPath := filepath.Join(dir, "css", "variables.css") + if err := os.WriteFile(varsPath, []byte(varsTemplate), 0644); err != nil { + return fmt.Errorf("failed to write variables.css: %w", err) + } + + return nil +} + +// generateGenericAssets creates simple geometric placeholder images +func generateGenericAssets(dir string) error { + // Color scheme: neutral blue-gray + primaryColor := color.RGBA{R: 64, G: 128, B: 192, A: 255} // #4080C0 - professional blue + transparent := color.RGBA{R: 0, G: 0, B: 0, A: 0} // Transparent background + + // Generate each size + sizes := map[string]int{ + "logo.png": 256, + "favicon.png": 64, + "icon-192.png": 192, + "icon-512.png": 512, + } + + for filename, size := range sizes { + img := generateRoundedSquare(size, primaryColor, transparent) + path := filepath.Join(dir, "assets", filename) + if err := savePNG(path, img); err != nil { + return fmt.Errorf("failed to save %s: %w", filename, err) + } + } + + return nil +} + +// generateRoundedSquare creates a simple rounded square icon +func generateRoundedSquare(size int, primary, bg color.RGBA) image.Image { + img := image.NewRGBA(image.Rect(0, 0, size, size)) + + // Fill background + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + img.Set(x, y, bg) + } + } + + // Draw a rounded square in the center + margin := size / 8 + cornerRadius := size / 6 + squareSize := size - (margin * 2) + + for y := margin; y < margin+squareSize; y++ { + for x := margin; x < margin+squareSize; x++ { + // Check if point is inside rounded rectangle + if isInsideRoundedRect(x-margin, y-margin, squareSize, squareSize, cornerRadius) { + img.Set(x, y, primary) + } + } + } + + // Draw a simple inner circle (relay symbol) + centerX := size / 2 + centerY := size / 2 + innerRadius := size / 5 + ringWidth := size / 20 + + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + dx := float64(x - centerX) + dy := float64(y - centerY) + dist := math.Sqrt(dx*dx + dy*dy) + + // Ring (circle outline) + if dist >= float64(innerRadius-ringWidth) && dist <= float64(innerRadius) { + img.Set(x, y, bg) + } + } + } + + return img +} + +// isInsideRoundedRect checks if a point is inside a rounded rectangle +func isInsideRoundedRect(x, y, w, h, r int) bool { + // Check corners + if x < r && y < r { + // Top-left corner + return isInsideCircle(x, y, r, r, r) + } + if x >= w-r && y < r { + // Top-right corner + return isInsideCircle(x, y, w-r-1, r, r) + } + if x < r && y >= h-r { + // Bottom-left corner + return isInsideCircle(x, y, r, h-r-1, r) + } + if x >= w-r && y >= h-r { + // Bottom-right corner + return isInsideCircle(x, y, w-r-1, h-r-1, r) + } + + // Inside main rectangle + return x >= 0 && x < w && y >= 0 && y < h +} + +// isInsideCircle checks if a point is inside a circle +func isInsideCircle(x, y, cx, cy, r int) bool { + dx := x - cx + dy := y - cy + return dx*dx+dy*dy <= r*r +} + +// savePNG saves an image as a PNG file +func savePNG(path string, img image.Image) error { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0644) +} + +// GenericConfig returns a generic/white-label configuration +func GenericConfig() Config { + return Config{ + Version: 1, + App: AppConfig{ + Name: "Relay", + ShortName: "Relay", + Title: "Relay Dashboard", + Description: "Nostr relay service", + }, + NIP11: NIP11Config{ + Name: "Relay", + Description: "A Nostr relay", + Icon: "", + }, + Manifest: ManifestConfig{ + ThemeColor: "#4080C0", + BackgroundColor: "#F0F4F8", + }, + Assets: AssetsConfig{ + Logo: "assets/logo.png", + Favicon: "assets/favicon.png", + Icon192: "assets/icon-192.png", + Icon512: "assets/icon-512.png", + }, + CSS: CSSConfig{ + CustomCSS: "css/custom.css", + VariablesCSS: "css/variables.css", + }, + } +} + +// CSSTemplate is the full CSS template with all variables and documentation +const CSSTemplate = `/* + * Custom Branding CSS for ORLY Relay + * ================================== + * + * This file is loaded AFTER the default styles, so any rules here + * will override the defaults. You can customize: + * + * 1. CSS Variables (colors, spacing, etc.) + * 2. Component styles (buttons, cards, headers, etc.) + * 3. Add completely custom styles + * + * Restart the relay to apply changes. + * + * For variable-only overrides, edit variables.css instead. + */ + +/* ============================================================================= + LIGHT THEME VARIABLES + ============================================================================= */ + +:root { + /* Background colors */ + --bg-color: #ddd; /* Main page background */ + --header-bg: #eee; /* Header background */ + --sidebar-bg: #eee; /* Sidebar background */ + --card-bg: #f8f9fa; /* Card/container background */ + --panel-bg: #f8f9fa; /* Panel background */ + + /* Border colors */ + --border-color: #dee2e6; /* Default border color */ + + /* Text colors */ + --text-color: #444444; /* Primary text color */ + --text-muted: #6c757d; /* Secondary/muted text */ + + /* Input/form colors */ + --input-border: #ccc; /* Input border color */ + --input-bg: #ffffff; /* Input background */ + --input-text-color: #495057; /* Input text color */ + + /* Button colors */ + --button-bg: #ddd; /* Default button background */ + --button-hover-bg: #eee; /* Button hover background */ + --button-text: #444444; /* Button text color */ + --button-hover-border: #adb5bd; /* Button hover border */ + + /* Theme/accent colors */ + --primary: #00bcd4; /* Primary accent (cyan) */ + --primary-bg: rgba(0, 188, 212, 0.1); /* Primary background tint */ + --secondary: #6c757d; /* Secondary color */ + + /* Status colors */ + --success: #28a745; /* Success/positive */ + --success-bg: #d4edda; /* Success background */ + --success-text: #155724; /* Success text */ + --info: #17a2b8; /* Info/neutral */ + --warning: #ff3e00; /* Warning (Svelte orange) */ + --warning-bg: #fff3cd; /* Warning background */ + --danger: #dc3545; /* Danger/error */ + --danger-bg: #f8d7da; /* Danger background */ + --danger-text: #721c24; /* Danger text */ + --error-bg: #f8d7da; /* Error background */ + --error-text: #721c24; /* Error text */ + + /* Code block colors */ + --code-bg: #f8f9fa; /* Code block background */ + --code-text: #495057; /* Code text color */ + + /* Tab colors */ + --tab-inactive-bg: #bbb; /* Inactive tab background */ + + /* Link/accent colors */ + --accent-color: #007bff; /* Link color */ + --accent-hover-color: #0056b3; /* Link hover color */ +} + +/* ============================================================================= + DARK THEME VARIABLES + ============================================================================= */ + +body.dark-theme { + /* Background colors */ + --bg-color: #263238; /* Main page background */ + --header-bg: #1e272c; /* Header background */ + --sidebar-bg: #1e272c; /* Sidebar background */ + --card-bg: #37474f; /* Card/container background */ + --panel-bg: #37474f; /* Panel background */ + + /* Border colors */ + --border-color: #404040; /* Default border color */ + + /* Text colors */ + --text-color: #ffffff; /* Primary text color */ + --text-muted: #adb5bd; /* Secondary/muted text */ + + /* Input/form colors */ + --input-border: #555; /* Input border color */ + --input-bg: #37474f; /* Input background */ + --input-text-color: #ffffff; /* Input text color */ + + /* Button colors */ + --button-bg: #263238; /* Default button background */ + --button-hover-bg: #1e272c; /* Button hover background */ + --button-text: #ffffff; /* Button text color */ + --button-hover-border: #6c757d; /* Button hover border */ + + /* Theme/accent colors */ + --primary: #00bcd4; /* Primary accent (cyan) */ + --primary-bg: rgba(0, 188, 212, 0.2); /* Primary background tint */ + --secondary: #6c757d; /* Secondary color */ + + /* Status colors */ + --success: #28a745; /* Success/positive */ + --success-bg: #1e4620; /* Success background (dark) */ + --success-text: #d4edda; /* Success text (light) */ + --info: #17a2b8; /* Info/neutral */ + --warning: #ff3e00; /* Warning (Svelte orange) */ + --warning-bg: #4d1f00; /* Warning background (dark) */ + --danger: #dc3545; /* Danger/error */ + --danger-bg: #4d1319; /* Danger background (dark) */ + --danger-text: #f8d7da; /* Danger text (light) */ + --error-bg: #4d1319; /* Error background */ + --error-text: #f8d7da; /* Error text */ + + /* Code block colors */ + --code-bg: #1e272c; /* Code block background */ + --code-text: #ffffff; /* Code text color */ + + /* Tab colors */ + --tab-inactive-bg: #1a1a1a; /* Inactive tab background */ + + /* Link/accent colors */ + --accent-color: #007bff; /* Link color */ + --accent-hover-color: #0056b3; /* Link hover color */ +} + +/* ============================================================================= + CUSTOM STYLES + Add your custom CSS rules below. These will override any default styles. + ============================================================================= */ + +/* Example: Custom header styling +.header { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} +*/ + +/* Example: Custom button styling +.btn { + border-radius: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +*/ + +/* Example: Custom card styling +.card { + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} +*/ +` + +// CSSVariablesTemplate contains only CSS variable definitions +const CSSVariablesTemplate = `/* + * CSS Variables Override for ORLY Relay + * ====================================== + * + * This file contains only CSS variable definitions. + * Edit values here to customize colors without touching component styles. + * + * For full CSS customization (including component styles), + * edit custom.css instead. + */ + +/* Light theme variables */ +:root { + --bg-color: #ddd; + --header-bg: #eee; + --sidebar-bg: #eee; + --card-bg: #f8f9fa; + --panel-bg: #f8f9fa; + --border-color: #dee2e6; + --text-color: #444444; + --text-muted: #6c757d; + --input-border: #ccc; + --input-bg: #ffffff; + --input-text-color: #495057; + --button-bg: #ddd; + --button-hover-bg: #eee; + --button-text: #444444; + --button-hover-border: #adb5bd; + --primary: #00bcd4; + --primary-bg: rgba(0, 188, 212, 0.1); + --secondary: #6c757d; + --success: #28a745; + --success-bg: #d4edda; + --success-text: #155724; + --info: #17a2b8; + --warning: #ff3e00; + --warning-bg: #fff3cd; + --danger: #dc3545; + --danger-bg: #f8d7da; + --danger-text: #721c24; + --error-bg: #f8d7da; + --error-text: #721c24; + --code-bg: #f8f9fa; + --code-text: #495057; + --tab-inactive-bg: #bbb; + --accent-color: #007bff; + --accent-hover-color: #0056b3; +} + +/* Dark theme variables */ +body.dark-theme { + --bg-color: #263238; + --header-bg: #1e272c; + --sidebar-bg: #1e272c; + --card-bg: #37474f; + --panel-bg: #37474f; + --border-color: #404040; + --text-color: #ffffff; + --text-muted: #adb5bd; + --input-border: #555; + --input-bg: #37474f; + --input-text-color: #ffffff; + --button-bg: #263238; + --button-hover-bg: #1e272c; + --button-text: #ffffff; + --button-hover-border: #6c757d; + --primary: #00bcd4; + --primary-bg: rgba(0, 188, 212, 0.2); + --secondary: #6c757d; + --success: #28a745; + --success-bg: #1e4620; + --success-text: #d4edda; + --info: #17a2b8; + --warning: #ff3e00; + --warning-bg: #4d1f00; + --danger: #dc3545; + --danger-bg: #4d1319; + --danger-text: #f8d7da; + --error-bg: #4d1319; + --error-text: #f8d7da; + --code-bg: #1e272c; + --code-text: #ffffff; + --tab-inactive-bg: #1a1a1a; + --accent-color: #007bff; + --accent-hover-color: #0056b3; +} +` + +// GenericCSSTemplate is the CSS template for generic/white-label branding +const GenericCSSTemplate = `/* + * Custom Branding CSS - White Label Template + * ========================================== + * + * This file is loaded AFTER the default styles, so any rules here + * will override the defaults. You can customize: + * + * 1. CSS Variables (colors, spacing, etc.) + * 2. Component styles (buttons, cards, headers, etc.) + * 3. Add completely custom styles + * + * Restart the relay to apply changes. + * + * For variable-only overrides, edit variables.css instead. + */ + +/* ============================================================================= + LIGHT THEME VARIABLES - Professional Blue-Gray + ============================================================================= */ + +html, body { + /* Background colors */ + --bg-color: #F0F4F8; /* Light gray-blue background */ + --header-bg: #FFFFFF; /* Clean white header */ + --sidebar-bg: #FFFFFF; /* Clean white sidebar */ + --card-bg: #FFFFFF; /* White cards */ + --panel-bg: #FFFFFF; /* White panels */ + + /* Border colors */ + --border-color: #E2E8F0; /* Subtle gray border */ + + /* Text colors */ + --text-color: #334155; /* Dark slate text */ + --text-muted: #64748B; /* Muted slate */ + + /* Input/form colors */ + --input-border: #CBD5E1; /* Light slate border */ + --input-bg: #FFFFFF; /* White input */ + --input-text-color: #334155; /* Dark slate text */ + + /* Button colors */ + --button-bg: #F1F5F9; /* Light slate button */ + --button-hover-bg: #E2E8F0; /* Slightly darker on hover */ + --button-text: #334155; /* Dark slate text */ + --button-hover-border: #94A3B8; /* Medium slate border */ + + /* Theme/accent colors - Professional Blue */ + --primary: #4080C0; /* Professional blue */ + --primary-bg: rgba(64, 128, 192, 0.1); /* Light blue tint */ + --secondary: #64748B; /* Slate gray */ + + /* Status colors */ + --success: #22C55E; /* Green */ + --success-bg: #DCFCE7; /* Light green */ + --success-text: #166534; /* Dark green */ + --info: #3B82F6; /* Blue */ + --warning: #F59E0B; /* Amber */ + --warning-bg: #FEF3C7; /* Light amber */ + --danger: #EF4444; /* Red */ + --danger-bg: #FEE2E2; /* Light red */ + --danger-text: #991B1B; /* Dark red */ + --error-bg: #FEE2E2; /* Light red */ + --error-text: #991B1B; /* Dark red */ + + /* Code block colors */ + --code-bg: #F8FAFC; /* Very light slate */ + --code-text: #334155; /* Dark slate */ + + /* Tab colors */ + --tab-inactive-bg: #E2E8F0; /* Light slate */ + + /* Link/accent colors */ + --accent-color: #4080C0; /* Professional blue */ + --accent-hover-color: #2563EB; /* Darker blue */ +} + +/* ============================================================================= + DARK THEME VARIABLES - Professional Dark + ============================================================================= */ + +body.dark-theme { + /* Background colors */ + --bg-color: #0F172A; /* Dark navy */ + --header-bg: #1E293B; /* Slate gray */ + --sidebar-bg: #1E293B; /* Slate gray */ + --card-bg: #1E293B; /* Slate gray */ + --panel-bg: #1E293B; /* Slate gray */ + + /* Border colors */ + --border-color: #334155; /* Medium slate */ + + /* Text colors */ + --text-color: #F8FAFC; /* Almost white */ + --text-muted: #94A3B8; /* Muted slate */ + + /* Input/form colors */ + --input-border: #475569; /* Slate border */ + --input-bg: #1E293B; /* Slate background */ + --input-text-color: #F8FAFC; /* Light text */ + + /* Button colors */ + --button-bg: #334155; /* Slate button */ + --button-hover-bg: #475569; /* Lighter on hover */ + --button-text: #F8FAFC; /* Light text */ + --button-hover-border: #64748B; /* Medium slate */ + + /* Theme/accent colors */ + --primary: #60A5FA; /* Lighter blue for dark mode */ + --primary-bg: rgba(96, 165, 250, 0.2); /* Blue tint */ + --secondary: #94A3B8; /* Muted slate */ + + /* Status colors */ + --success: #4ADE80; /* Bright green */ + --success-bg: #166534; /* Dark green */ + --success-text: #DCFCE7; /* Light green */ + --info: #60A5FA; /* Light blue */ + --warning: #FBBF24; /* Bright amber */ + --warning-bg: #78350F; /* Dark amber */ + --danger: #F87171; /* Light red */ + --danger-bg: #7F1D1D; /* Dark red */ + --danger-text: #FEE2E2; /* Light red */ + --error-bg: #7F1D1D; /* Dark red */ + --error-text: #FEE2E2; /* Light red */ + + /* Code block colors */ + --code-bg: #0F172A; /* Dark navy */ + --code-text: #F8FAFC; /* Light text */ + + /* Tab colors */ + --tab-inactive-bg: #1E293B; /* Slate */ + + /* Link/accent colors */ + --accent-color: #60A5FA; /* Light blue */ + --accent-hover-color: #93C5FD; /* Lighter blue */ +} + +/* ============================================================================= + PRIMARY BUTTON TEXT COLOR FIX + Ensures buttons with primary background have white text for contrast + ============================================================================= */ + +/* Target all common button patterns that use primary background */ +button[class*="-btn"], +button[class*="submit"], +button[class*="action"], +button[class*="save"], +button[class*="add"], +button[class*="create"], +button[class*="connect"], +button[class*="refresh"], +button[class*="retry"], +button[class*="send"], +button[class*="apply"], +button[class*="execute"], +button[class*="run"], +.primary-action, +.action-button, +.permission-badge, +[class*="badge"] { + color: #FFFFFF !important; +} + +/* More specific override for any button that visually appears to have primary bg */ +/* This uses a broad selector with low impact on non-primary buttons */ +html:not(.dark-theme) button:not([disabled]) { + /* Default to inherit, primary buttons will be caught above */ +} + +/* ============================================================================= + CUSTOM STYLES + Add your custom CSS rules below. These will override any default styles. + ============================================================================= */ + +/* Example: Custom header styling +.header { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} +*/ + +/* Example: Custom button styling +.btn { + border-radius: 6px; + font-weight: 500; +} +*/ + +/* Example: Custom card styling +.card { + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} +*/ +` + +// GenericCSSVariablesTemplate contains CSS variables for generic/white-label branding +const GenericCSSVariablesTemplate = `/* + * CSS Variables Override - White Label Template + * ============================================== + * + * This file contains only CSS variable definitions. + * Edit values here to customize colors without touching component styles. + * + * For full CSS customization (including component styles), + * edit custom.css instead. + */ + +/* Light theme variables - Professional Blue-Gray */ +/* Applied to both html and body for maximum compatibility */ +html, body { + --bg-color: #F0F4F8; + --header-bg: #FFFFFF; + --sidebar-bg: #FFFFFF; + --card-bg: #FFFFFF; + --panel-bg: #FFFFFF; + --border-color: #E2E8F0; + --text-color: #334155; + --text-muted: #64748B; + --input-border: #CBD5E1; + --input-bg: #FFFFFF; + --input-text-color: #334155; + --button-bg: #F1F5F9; + --button-hover-bg: #E2E8F0; + --button-text: #334155; + --button-hover-border: #94A3B8; + --primary: #4080C0; + --primary-bg: rgba(64, 128, 192, 0.1); + --secondary: #64748B; + --success: #22C55E; + --success-bg: #DCFCE7; + --success-text: #166534; + --info: #3B82F6; + --warning: #F59E0B; + --warning-bg: #FEF3C7; + --danger: #EF4444; + --danger-bg: #FEE2E2; + --danger-text: #991B1B; + --error-bg: #FEE2E2; + --error-text: #991B1B; + --code-bg: #F8FAFC; + --code-text: #334155; + --tab-inactive-bg: #E2E8F0; + --accent-color: #4080C0; + --accent-hover-color: #2563EB; +} + +/* Dark theme variables - Professional Dark */ +body.dark-theme { + --bg-color: #0F172A; + --header-bg: #1E293B; + --sidebar-bg: #1E293B; + --card-bg: #1E293B; + --panel-bg: #1E293B; + --border-color: #334155; + --text-color: #F8FAFC; + --text-muted: #94A3B8; + --input-border: #475569; + --input-bg: #1E293B; + --input-text-color: #F8FAFC; + --button-bg: #334155; + --button-hover-bg: #475569; + --button-text: #F8FAFC; + --button-hover-border: #64748B; + --primary: #60A5FA; + --primary-bg: rgba(96, 165, 250, 0.2); + --secondary: #94A3B8; + --success: #4ADE80; + --success-bg: #166534; + --success-text: #DCFCE7; + --info: #60A5FA; + --warning: #FBBF24; + --warning-bg: #78350F; + --danger: #F87171; + --danger-bg: #7F1D1D; + --danger-text: #FEE2E2; + --error-bg: #7F1D1D; + --error-text: #FEE2E2; + --code-bg: #0F172A; + --code-text: #F8FAFC; + --tab-inactive-bg: #1E293B; + --accent-color: #60A5FA; + --accent-hover-color: #93C5FD; +} +` diff --git a/app/branding/types.go b/app/branding/types.go new file mode 100644 index 0000000..12658d8 --- /dev/null +++ b/app/branding/types.go @@ -0,0 +1,81 @@ +// Package branding provides white-label customization for the ORLY relay web UI. +// It allows relay operators to customize the appearance, branding, and theme +// without rebuilding the application. +package branding + +// Config is the main configuration structure loaded from branding.json +type Config struct { + Version int `json:"version"` + App AppConfig `json:"app"` + NIP11 NIP11Config `json:"nip11"` + Manifest ManifestConfig `json:"manifest"` + Assets AssetsConfig `json:"assets"` + CSS CSSConfig `json:"css"` +} + +// AppConfig contains application-level branding settings +type AppConfig struct { + Name string `json:"name"` // Display name (e.g., "My Relay") + ShortName string `json:"shortName"` // Short name for PWA (e.g., "Relay") + Title string `json:"title"` // Browser tab title (e.g., "My Relay Dashboard") + Description string `json:"description"` // Brief description +} + +// NIP11Config contains settings for the NIP-11 relay information document +type NIP11Config struct { + Name string `json:"name"` // Relay name in NIP-11 response + Description string `json:"description"` // Relay description in NIP-11 response + Icon string `json:"icon"` // Icon URL for NIP-11 response +} + +// ManifestConfig contains PWA manifest customization +type ManifestConfig struct { + ThemeColor string `json:"themeColor"` // Theme color (e.g., "#1a1a2e") + BackgroundColor string `json:"backgroundColor"` // Background color (e.g., "#16213e") +} + +// AssetsConfig contains paths to custom asset files (relative to branding directory) +type AssetsConfig struct { + Logo string `json:"logo"` // Header logo image (replaces orly.png) + Favicon string `json:"favicon"` // Browser favicon + Icon192 string `json:"icon192"` // PWA icon 192x192 + Icon512 string `json:"icon512"` // PWA icon 512x512 +} + +// CSSConfig contains paths to custom CSS files (relative to branding directory) +type CSSConfig struct { + CustomCSS string `json:"customCSS"` // Full CSS override file + VariablesCSS string `json:"variablesCSS"` // CSS variables override file (optional) +} + +// DefaultConfig returns a default configuration with example values +func DefaultConfig() Config { + return Config{ + Version: 1, + App: AppConfig{ + Name: "My Relay", + ShortName: "Relay", + Title: "My Relay Dashboard", + Description: "A high-performance Nostr relay", + }, + NIP11: NIP11Config{ + Name: "My Relay", + Description: "Custom relay description", + Icon: "", + }, + Manifest: ManifestConfig{ + ThemeColor: "#000000", + BackgroundColor: "#000000", + }, + Assets: AssetsConfig{ + Logo: "assets/logo.png", + Favicon: "assets/favicon.png", + Icon192: "assets/icon-192.png", + Icon512: "assets/icon-512.png", + }, + CSS: CSSConfig{ + CustomCSS: "css/custom.css", + VariablesCSS: "css/variables.css", + }, + } +} diff --git a/app/config/config.go b/app/config/config.go index 657dbd1..eeeaf00 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -85,6 +85,10 @@ type C struct { WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"` WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"` + // Branding/white-label settings + BrandingDir string `env:"ORLY_BRANDING_DIR" usage:"directory containing branding assets and configuration (default: ~/.config/ORLY/branding)"` + BrandingEnabled bool `env:"ORLY_BRANDING_ENABLED" default:"true" usage:"enable custom branding if branding directory exists"` + // Sprocket settings SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"` @@ -445,6 +449,36 @@ func NRCRequested() (requested bool, subcommand string, args []string) { return } +// InitBrandingRequested checks if the first command line argument is "init-branding" +// and returns the target directory and style if provided. +// +// Return Values +// - requested: true if the 'init-branding' subcommand was provided +// - targetDir: optional target directory for branding files (default: ~/.config/ORLY/branding) +// - style: branding style ("orly" or "generic", default: "generic") +// +// Usage: orly init-branding [--style orly|generic] [path] +func InitBrandingRequested() (requested bool, targetDir, style string) { + style = "generic" // default to generic/white-label + if len(os.Args) > 1 { + switch strings.ToLower(os.Args[1]) { + case "init-branding": + requested = true + // Parse remaining arguments + for i := 2; i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "--style" && i+1 < len(os.Args) { + style = strings.ToLower(os.Args[i+1]) + i++ // skip next arg + } else if !strings.HasPrefix(arg, "-") { + targetDir = arg + } + } + } + } + return +} + // KV is a key/value pair. type KV struct{ Key, Value string } @@ -576,11 +610,16 @@ func PrintHelp(cfg *C, printer io.Writer) { ) _, _ = fmt.Fprintf( printer, - `Usage: %s [env|help|identity|migrate|serve|version] + `Usage: %s [env|help|identity|init-branding|migrate|serve|version] - env: print environment variables configuring %s - help: print this help text - identity: print the relay identity secret and public key +- init-branding: create branding directory with default assets and CSS templates + Example: %s init-branding [--style generic|orly] [/path/to/branding] + Styles: generic (default) - neutral white-label branding + orly - ORLY-branded assets + Default location: ~/.config/%s/branding - migrate: migrate data between database backends Example: %s migrate --from badger --to bbolt - serve: start ephemeral relay with RAM-based storage at /dev/shm/orlyserve @@ -589,7 +628,7 @@ func PrintHelp(cfg *C, printer io.Writer) { - version: print version and exit (also: -v, --v, -version, --version) `, - cfg.AppName, cfg.AppName, cfg.AppName, + cfg.AppName, cfg.AppName, cfg.AppName, cfg.AppName, cfg.AppName, ) _, _ = fmt.Fprintf( printer, diff --git a/app/handle-relayinfo.go b/app/handle-relayinfo.go index d49675d..84b99ce 100644 --- a/app/handle-relayinfo.go +++ b/app/handle-relayinfo.go @@ -115,6 +115,20 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) { description := version.Description + " dashboard: " + s.DashboardURL(r) icon := "https://i.nostr.build/6wGXAn7Zaw9mHxFg.png" + // Override with branding config if available + if s.brandingMgr != nil { + nip11 := s.brandingMgr.NIP11Config() + if nip11.Name != "" { + name = nip11.Name + } + if nip11.Description != "" { + description = nip11.Description + } + if nip11.Icon != "" { + icon = nip11.Icon + } + } + // Override with managed ACL config if in managed mode if s.Config.ACLMode == "managed" { // Get managed ACL instance diff --git a/app/main.go b/app/main.go index 7d34721..093e34b 100644 --- a/app/main.go +++ b/app/main.go @@ -10,9 +10,11 @@ import ( "sync" "time" + "github.com/adrg/xdg" "golang.org/x/crypto/acme/autocert" "lol.mleku.dev/chk" "lol.mleku.dev/log" + "next.orly.dev/app/branding" "next.orly.dev/app/config" "next.orly.dev/pkg/acl" "git.mleku.dev/mleku/nostr/crypto/keys" @@ -91,6 +93,21 @@ func Run( db: db, } + // Initialize branding/white-label manager if enabled + if cfg.BrandingEnabled { + brandingDir := cfg.BrandingDir + if brandingDir == "" { + brandingDir = filepath.Join(xdg.ConfigHome, cfg.AppName, "branding") + } + if _, err := os.Stat(brandingDir); err == nil { + if l.brandingMgr, err = branding.New(brandingDir); err != nil { + log.W.F("failed to load branding from %s: %v", brandingDir, err) + } else { + log.I.F("custom branding loaded from %s", brandingDir) + } + } + } + // Initialize NIP-43 invite manager if enabled if cfg.NIP43Enabled { l.InviteManager = nip43.NewInviteManager(cfg.NIP43InviteExpiry) diff --git a/app/server.go b/app/server.go index f293b43..def6f67 100644 --- a/app/server.go +++ b/app/server.go @@ -15,6 +15,7 @@ import ( "time" "lol.mleku.dev/chk" + "next.orly.dev/app/branding" "next.orly.dev/app/config" "next.orly.dev/pkg/acl" "next.orly.dev/pkg/blossom" @@ -106,6 +107,9 @@ type Server struct { // Tor hidden service torService *tor.Service + + // Branding/white-label customization + brandingMgr *branding.Manager } // isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system @@ -302,6 +306,12 @@ func (s *Server) UserInterface() { // Serve favicon.ico by serving favicon.png s.mux.HandleFunc("/favicon.ico", s.handleFavicon) + // Branding/white-label endpoints (custom assets, CSS, manifest) + s.mux.HandleFunc("/branding/", s.handleBrandingAsset) + + // Intercept /orly.png to serve custom logo if branding is active + s.mux.HandleFunc("/orly.png", s.handleLogo) + // Serve the main login interface (and static assets) or proxy in dev mode s.mux.HandleFunc("/", s.handleLoginInterface) @@ -401,6 +411,16 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) { return } + // Check for custom branding favicon first + if s.brandingMgr != nil { + if data, mimeType, ok := s.brandingMgr.GetAsset("favicon"); ok { + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(data) + return + } + } + // Serve favicon.png as favicon.ico from embedded web app w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day @@ -413,6 +433,30 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) { ServeEmbeddedWeb(w, faviconReq) } +// handleLogo serves the logo image, using custom branding if available +func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) { + // In dev mode with proxy configured, forward to dev server + if s.devProxy != nil { + s.devProxy.ServeHTTP(w, r) + return + } + + // Check for custom branding logo first + if s.brandingMgr != nil { + if data, mimeType, ok := s.brandingMgr.GetAsset("logo"); ok { + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(data) + return + } + } + + // Fall back to embedded orly.png + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=86400") + ServeEmbeddedWeb(w, r) +} + // handleLoginInterface serves the main user interface for login func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) { // In dev mode with proxy configured, forward to dev server @@ -427,10 +471,133 @@ func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) { return } + // If branding is enabled and this is the index page, inject customizations + if s.brandingMgr != nil && (r.URL.Path == "/" || r.URL.Path == "/index.html") { + s.serveModifiedIndex(w, r) + return + } + // Serve embedded web interface ServeEmbeddedWeb(w, r) } +// serveModifiedIndex serves the index.html with branding modifications injected +func (s *Server) serveModifiedIndex(w http.ResponseWriter, r *http.Request) { + // Read the embedded index.html + fs := GetReactAppFS() + file, err := fs.Open("index.html") + if err != nil { + // Fallback to embedded serving + ServeEmbeddedWeb(w, r) + return + } + defer file.Close() + + originalHTML, err := io.ReadAll(file) + if err != nil { + ServeEmbeddedWeb(w, r) + return + } + + // Apply branding modifications + modifiedHTML, err := s.brandingMgr.ModifyIndexHTML(originalHTML) + if err != nil { + ServeEmbeddedWeb(w, r) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Write(modifiedHTML) +} + +// handleBrandingAsset serves custom branding assets (logo, icons, CSS, manifest) +func (s *Server) handleBrandingAsset(w http.ResponseWriter, r *http.Request) { + // Extract asset name from path: /branding/logo.png -> logo.png + path := strings.TrimPrefix(r.URL.Path, "/branding/") + + // If no branding manager, return 404 + if s.brandingMgr == nil { + http.NotFound(w, r) + return + } + + switch path { + case "custom.css": + // Serve combined custom CSS + css, err := s.brandingMgr.GetCustomCSS() + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(css) + + case "manifest.json": + // Serve customized manifest.json + // First read the embedded manifest + fs := GetReactAppFS() + file, err := fs.Open("manifest.json") + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + originalManifest, err := io.ReadAll(file) + if err != nil { + http.NotFound(w, r) + return + } + + manifest, err := s.brandingMgr.GetManifest(originalManifest) + if err != nil { + // Fallback to original + w.Header().Set("Content-Type", "application/manifest+json") + w.Write(originalManifest) + return + } + + w.Header().Set("Content-Type", "application/manifest+json") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(manifest) + + case "logo.png": + s.serveBrandingAsset(w, "logo") + + case "favicon.png": + s.serveBrandingAsset(w, "favicon") + + case "icon-192.png": + s.serveBrandingAsset(w, "icon-192") + + case "icon-512.png": + s.serveBrandingAsset(w, "icon-512") + + default: + http.NotFound(w, r) + } +} + +// serveBrandingAsset serves a specific branding asset by name +func (s *Server) serveBrandingAsset(w http.ResponseWriter, name string) { + if s.brandingMgr == nil { + http.NotFound(w, nil) + return + } + + data, mimeType, ok := s.brandingMgr.GetAsset(name) + if !ok { + http.NotFound(w, nil) + return + } + + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(data) +} + // handleAuthChallenge generates a new authentication challenge func (s *Server) handleAuthChallenge(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/app/web.go b/app/web.go index 512b071..5a1cbce 100644 --- a/app/web.go +++ b/app/web.go @@ -23,3 +23,9 @@ func ServeEmbeddedWeb(w http.ResponseWriter, r *http.Request) { // Serve the embedded web app http.FileServer(GetReactAppFS()).ServeHTTP(w, r) } + +// GetEmbeddedWebFS returns the raw embedded filesystem for branding initialization. +// This is used by the init-branding command to extract default assets. +func GetEmbeddedWebFS() embed.FS { + return reactAppFS +} diff --git a/docs/BRANDING_GUIDE.md b/docs/BRANDING_GUIDE.md new file mode 100644 index 0000000..2509026 --- /dev/null +++ b/docs/BRANDING_GUIDE.md @@ -0,0 +1,246 @@ +# White-Label Branding Guide + +ORLY supports full white-label branding, allowing relay operators to customize the UI appearance without rebuilding the application. All branding is loaded at runtime from a configuration directory. + +## Quick Start + +Generate a branding kit: + +```bash +# Generic/white-label branding (recommended for customization) +./orly init-branding --style generic + +# ORLY-branded template +./orly init-branding --style orly + +# Custom output directory +./orly init-branding --style generic /path/to/branding +``` + +The branding kit is created at `~/.config/ORLY/branding/` by default. + +## Directory Structure + +``` +~/.config/ORLY/branding/ + branding.json # Main configuration + assets/ + logo.png # Header logo (replaces default) + favicon.png # Browser favicon + icon-192.png # PWA icon 192x192 + icon-512.png # PWA icon 512x512 + css/ + custom.css # Full CSS override + variables.css # CSS variable overrides only +``` + +## Configuration (branding.json) + +```json +{ + "version": 1, + "app": { + "name": "My Relay", + "shortName": "Relay", + "title": "My Relay Dashboard", + "description": "A high-performance Nostr relay" + }, + "nip11": { + "name": "My Relay", + "description": "Custom relay description for NIP-11", + "icon": "https://example.com/icon.png" + }, + "manifest": { + "themeColor": "#4080C0", + "backgroundColor": "#F0F4F8" + }, + "assets": { + "logo": "assets/logo.png", + "favicon": "assets/favicon.png", + "icon192": "assets/icon-192.png", + "icon512": "assets/icon-512.png" + }, + "css": { + "customCSS": "css/custom.css", + "variablesCSS": "css/variables.css" + } +} +``` + +### Configuration Sections + +| Section | Description | +|---------|-------------| +| `app` | Application name and titles displayed in the UI | +| `nip11` | NIP-11 relay information document fields | +| `manifest` | PWA manifest colors | +| `assets` | Paths to custom images (relative to branding dir) | +| `css` | Paths to custom CSS files | + +## Custom Assets + +Replace the generated placeholder images with your own: + +| Asset | Size | Purpose | +|-------|------|---------| +| `logo.png` | 256x256 recommended | Header logo | +| `favicon.png` | 64x64 | Browser tab icon | +| `icon-192.png` | 192x192 | PWA icon (Android) | +| `icon-512.png` | 512x512 | PWA splash screen | + +**Tip**: Use PNG format with transparency for best results. + +## CSS Customization + +### Quick Theme Changes (variables.css) + +Edit `css/variables.css` to change colors without touching component styles: + +```css +/* Light theme */ +html, body { + --bg-color: #F0F4F8; + --header-bg: #FFFFFF; + --primary: #4080C0; + --text-color: #334155; + /* ... see generated file for all variables */ +} + +/* Dark theme */ +body.dark-theme { + --bg-color: #0F172A; + --header-bg: #1E293B; + --primary: #60A5FA; + --text-color: #F8FAFC; +} +``` + +### Full CSS Override (custom.css) + +Edit `css/custom.css` for complete control over styling: + +```css +/* Custom header */ +.header { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +/* Custom buttons */ +button { + border-radius: 8px; + font-weight: 500; +} + +/* Custom cards */ +.card { + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} +``` + +### Available CSS Variables + +#### Background Colors +- `--bg-color` - Main page background +- `--header-bg` - Header background +- `--sidebar-bg` - Sidebar background +- `--card-bg` - Card/container background +- `--panel-bg` - Panel background + +#### Text Colors +- `--text-color` - Primary text +- `--text-muted` - Secondary/muted text + +#### Theme Colors +- `--primary` - Primary accent color +- `--primary-bg` - Primary background tint +- `--secondary` - Secondary color +- `--accent-color` - Link color +- `--accent-hover-color` - Link hover color + +#### Status Colors +- `--success`, `--success-bg`, `--success-text` +- `--warning`, `--warning-bg` +- `--danger`, `--danger-bg`, `--danger-text` +- `--info` + +#### Form/Input Colors +- `--input-bg` - Input background +- `--input-border` - Input border +- `--input-text-color` - Input text + +#### Button Colors +- `--button-bg` - Default button background +- `--button-hover-bg` - Button hover background +- `--button-text` - Button text color +- `--button-hover-border` - Button hover border + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ORLY_BRANDING_DIR` | `~/.config/ORLY/branding` | Branding directory path | +| `ORLY_BRANDING_ENABLED` | `true` | Enable/disable custom branding | + +## Applying Changes + +Restart the relay to apply branding changes: + +```bash +# Stop and start the relay +pkill orly +./orly +``` + +Changes to CSS and assets require a restart. The relay logs will show: + +``` +custom branding loaded from /home/user/.config/ORLY/branding +``` + +## Branding Endpoints + +The relay serves branding assets at these endpoints: + +| Endpoint | Description | +|----------|-------------| +| `/branding/logo.png` | Custom logo | +| `/branding/favicon.png` | Custom favicon | +| `/branding/icon-192.png` | PWA icon 192x192 | +| `/branding/icon-512.png` | PWA icon 512x512 | +| `/branding/custom.css` | Combined CSS (variables + custom) | +| `/branding/manifest.json` | Customized PWA manifest | + +## Disabling Branding + +To use the default ORLY branding: + +```bash +# Option 1: Remove branding directory +rm -rf ~/.config/ORLY/branding + +# Option 2: Disable via environment +ORLY_BRANDING_ENABLED=false ./orly +``` + +## Troubleshooting + +### Branding not loading +- Check that `~/.config/ORLY/branding/branding.json` exists +- Verify file permissions (readable by relay process) +- Check relay logs for branding load messages + +### CSS changes not appearing +- Hard refresh the browser (Ctrl+Shift+R) +- Clear browser cache +- Verify CSS syntax is valid + +### Logo not showing +- Ensure image path in `branding.json` is correct +- Check image file exists and is readable +- Use PNG format with appropriate dimensions + +### Colors look wrong in light/dark mode +- Light theme uses `html, body` selector +- Dark theme uses `body.dark-theme` selector +- Ensure both themes are defined if customizing diff --git a/main.go b/main.go index 3322e90..91129ce 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "os/signal" + "path/filepath" "runtime" "runtime/debug" "strings" @@ -15,11 +16,13 @@ import ( "syscall" "time" + "github.com/adrg/xdg" "github.com/pkg/profile" "golang.org/x/term" "lol.mleku.dev/chk" "lol.mleku.dev/log" "next.orly.dev/app" + "next.orly.dev/app/branding" "next.orly.dev/app/config" "next.orly.dev/pkg/acl" "git.mleku.dev/mleku/nostr/crypto/keys" @@ -49,6 +52,40 @@ func main() { } log.I.F("starting %s %s", cfg.AppName, version.V) + // Handle 'init-branding' subcommand: create branding directory with default assets + if requested, targetDir, style := config.InitBrandingRequested(); requested { + if targetDir == "" { + targetDir = filepath.Join(xdg.ConfigHome, cfg.AppName, "branding") + } + + // Validate and convert style + var brandingStyle branding.BrandingStyle + switch style { + case "orly": + brandingStyle = branding.StyleORLY + case "generic", "": + brandingStyle = branding.StyleGeneric + default: + fmt.Fprintf(os.Stderr, "Unknown style: %s (use 'orly' or 'generic')\n", style) + os.Exit(1) + } + + fmt.Printf("Initializing %s branding kit at: %s\n", style, targetDir) + if err := branding.InitBrandingKit(targetDir, app.GetEmbeddedWebFS(), brandingStyle); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Println("\nBranding kit created successfully!") + fmt.Println("\nFiles created:") + fmt.Println(" branding.json - Main configuration file") + fmt.Println(" assets/ - Logo, favicon, and PWA icons") + fmt.Println(" css/custom.css - Full CSS override template") + fmt.Println(" css/variables.css - CSS variables-only template") + fmt.Println("\nEdit these files to customize your relay's appearance.") + fmt.Println("Restart the relay to apply changes.") + os.Exit(0) + } + // Handle 'identity' subcommand: print relay identity secret and pubkey and exit if config.IdentityRequested() { ctx, cancel := context.WithCancel(context.Background())