Browse Source
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 <noreply@anthropic.com>main v0.52.0
10 changed files with 1740 additions and 2 deletions
@ -0,0 +1,341 @@
@@ -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 </head>
|
||||
if m.HasCustomCSS() { |
||||
cssLink := `<link rel="stylesheet" href="/branding/custom.css">` |
||||
html = strings.Replace(html, "</head>", cssLink+"\n</head>", 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(`<script> |
||||
(function() { |
||||
var appName = %q; |
||||
function updateBranding() { |
||||
var titles = document.querySelectorAll('.app-title'); |
||||
titles.forEach(function(el) { |
||||
var badge = el.querySelector('.permission-badge'); |
||||
el.childNodes.forEach(function(node) { |
||||
if (node.nodeType === 3 && node.textContent.trim()) { |
||||
node.textContent = appName + ' '; |
||||
} |
||||
}); |
||||
if (!el.textContent.includes(appName)) { |
||||
if (badge) { |
||||
el.innerHTML = appName + ' ' + badge.outerHTML; |
||||
} else { |
||||
el.textContent = appName; |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
if (document.readyState === 'loading') { |
||||
document.addEventListener('DOMContentLoaded', updateBranding); |
||||
} else { |
||||
updateBranding(); |
||||
} |
||||
// Also run periodically to catch Svelte updates
|
||||
setInterval(updateBranding, 500); |
||||
setTimeout(function() { clearInterval(this); }, 10000); |
||||
})(); |
||||
</script>`, m.config.App.Name+" dashboard") |
||||
html = strings.Replace(html, "</head>", brandingScript+"\n</head>", 1) |
||||
} |
||||
|
||||
// Replace title if custom title is set
|
||||
if m.config.App.Title != "" { |
||||
titleRegex := regexp.MustCompile(`<title>[^<]*</title>`) |
||||
html = titleRegex.ReplaceAllString(html, fmt.Sprintf("<title>%s</title>", 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() |
||||
} |
||||
@ -0,0 +1,790 @@
@@ -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; |
||||
} |
||||
` |
||||
@ -0,0 +1,81 @@
@@ -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", |
||||
}, |
||||
} |
||||
} |
||||
@ -0,0 +1,246 @@
@@ -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 |
||||
Loading…
Reference in new issue