feat(branding): add white-label branding system (v0.52.0)
Some checks are pending
Go / build-and-release (push) Waiting to run
Some checks are pending
Go / build-and-release (push) Waiting to run
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>
This commit is contained in:
341
app/branding/branding.go
Normal file
341
app/branding/branding.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
790
app/branding/init.go
Normal file
790
app/branding/init.go
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
`
|
||||||
81
app/branding/types.go
Normal file
81
app/branding/types.go
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
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)"`
|
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
|
// Sprocket settings
|
||||||
SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"`
|
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
|
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.
|
// KV is a key/value pair.
|
||||||
type KV struct{ Key, Value string }
|
type KV struct{ Key, Value string }
|
||||||
|
|
||||||
@@ -576,11 +610,16 @@ func PrintHelp(cfg *C, printer io.Writer) {
|
|||||||
)
|
)
|
||||||
_, _ = fmt.Fprintf(
|
_, _ = fmt.Fprintf(
|
||||||
printer,
|
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
|
- env: print environment variables configuring %s
|
||||||
- help: print this help text
|
- help: print this help text
|
||||||
- identity: print the relay identity secret and public key
|
- 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
|
- migrate: migrate data between database backends
|
||||||
Example: %s migrate --from badger --to bbolt
|
Example: %s migrate --from badger --to bbolt
|
||||||
- serve: start ephemeral relay with RAM-based storage at /dev/shm/orlyserve
|
- 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)
|
- 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(
|
_, _ = fmt.Fprintf(
|
||||||
printer,
|
printer,
|
||||||
|
|||||||
@@ -115,6 +115,20 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
description := version.Description + " dashboard: " + s.DashboardURL(r)
|
description := version.Description + " dashboard: " + s.DashboardURL(r)
|
||||||
icon := "https://i.nostr.build/6wGXAn7Zaw9mHxFg.png"
|
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
|
// Override with managed ACL config if in managed mode
|
||||||
if s.Config.ACLMode == "managed" {
|
if s.Config.ACLMode == "managed" {
|
||||||
// Get managed ACL instance
|
// Get managed ACL instance
|
||||||
|
|||||||
17
app/main.go
17
app/main.go
@@ -10,9 +10,11 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
"lol.mleku.dev/chk"
|
"lol.mleku.dev/chk"
|
||||||
"lol.mleku.dev/log"
|
"lol.mleku.dev/log"
|
||||||
|
"next.orly.dev/app/branding"
|
||||||
"next.orly.dev/app/config"
|
"next.orly.dev/app/config"
|
||||||
"next.orly.dev/pkg/acl"
|
"next.orly.dev/pkg/acl"
|
||||||
"git.mleku.dev/mleku/nostr/crypto/keys"
|
"git.mleku.dev/mleku/nostr/crypto/keys"
|
||||||
@@ -91,6 +93,21 @@ func Run(
|
|||||||
db: db,
|
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
|
// Initialize NIP-43 invite manager if enabled
|
||||||
if cfg.NIP43Enabled {
|
if cfg.NIP43Enabled {
|
||||||
l.InviteManager = nip43.NewInviteManager(cfg.NIP43InviteExpiry)
|
l.InviteManager = nip43.NewInviteManager(cfg.NIP43InviteExpiry)
|
||||||
|
|||||||
167
app/server.go
167
app/server.go
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lol.mleku.dev/chk"
|
"lol.mleku.dev/chk"
|
||||||
|
"next.orly.dev/app/branding"
|
||||||
"next.orly.dev/app/config"
|
"next.orly.dev/app/config"
|
||||||
"next.orly.dev/pkg/acl"
|
"next.orly.dev/pkg/acl"
|
||||||
"next.orly.dev/pkg/blossom"
|
"next.orly.dev/pkg/blossom"
|
||||||
@@ -106,6 +107,9 @@ type Server struct {
|
|||||||
|
|
||||||
// Tor hidden service
|
// Tor hidden service
|
||||||
torService *tor.Service
|
torService *tor.Service
|
||||||
|
|
||||||
|
// Branding/white-label customization
|
||||||
|
brandingMgr *branding.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system
|
// 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
|
// Serve favicon.ico by serving favicon.png
|
||||||
s.mux.HandleFunc("/favicon.ico", s.handleFavicon)
|
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
|
// Serve the main login interface (and static assets) or proxy in dev mode
|
||||||
s.mux.HandleFunc("/", s.handleLoginInterface)
|
s.mux.HandleFunc("/", s.handleLoginInterface)
|
||||||
|
|
||||||
@@ -401,6 +411,16 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Serve favicon.png as favicon.ico from embedded web app
|
||||||
w.Header().Set("Content-Type", "image/png")
|
w.Header().Set("Content-Type", "image/png")
|
||||||
w.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day
|
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)
|
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
|
// handleLoginInterface serves the main user interface for login
|
||||||
func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
|
||||||
// In dev mode with proxy configured, forward to dev server
|
// 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
|
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
|
// Serve embedded web interface
|
||||||
ServeEmbeddedWeb(w, r)
|
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
|
// handleAuthChallenge generates a new authentication challenge
|
||||||
func (s *Server) handleAuthChallenge(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthChallenge(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
|
|||||||
@@ -23,3 +23,9 @@ func ServeEmbeddedWeb(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Serve the embedded web app
|
// Serve the embedded web app
|
||||||
http.FileServer(GetReactAppFS()).ServeHTTP(w, r)
|
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
|
||||||
|
}
|
||||||
|
|||||||
246
docs/BRANDING_GUIDE.md
Normal file
246
docs/BRANDING_GUIDE.md
Normal file
@@ -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
|
||||||
37
main.go
37
main.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,11 +16,13 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
"github.com/pkg/profile"
|
"github.com/pkg/profile"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
"lol.mleku.dev/chk"
|
"lol.mleku.dev/chk"
|
||||||
"lol.mleku.dev/log"
|
"lol.mleku.dev/log"
|
||||||
"next.orly.dev/app"
|
"next.orly.dev/app"
|
||||||
|
"next.orly.dev/app/branding"
|
||||||
"next.orly.dev/app/config"
|
"next.orly.dev/app/config"
|
||||||
"next.orly.dev/pkg/acl"
|
"next.orly.dev/pkg/acl"
|
||||||
"git.mleku.dev/mleku/nostr/crypto/keys"
|
"git.mleku.dev/mleku/nostr/crypto/keys"
|
||||||
@@ -49,6 +52,40 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.I.F("starting %s %s", cfg.AppName, version.V)
|
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
|
// Handle 'identity' subcommand: print relay identity secret and pubkey and exit
|
||||||
if config.IdentityRequested() {
|
if config.IdentityRequested() {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|||||||
Reference in New Issue
Block a user