From e971a5f32989a267a599079a227b0fdbbf1fc0aa Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Fri, 13 Feb 2026 14:13:32 -0700 Subject: [PATCH] Add custom colors --- .claude/settings.local.json | 4 +- Media.JoshHeaps.Net/Api/ThemeApi.cs | 74 +++ .../Database/028_user_theme_overrides.sql | 11 + .../Models/UserThemeOverrides.cs | 11 + Media.JoshHeaps.Net/Pages/Profile.cshtml | 138 +----- Media.JoshHeaps.Net/Program.cs | 1 + Media.JoshHeaps.Net/Services/ThemeService.cs | 43 ++ Media.JoshHeaps.Net/wwwroot/css/profile.css | 140 ++++++ .../wwwroot/css/theme-customizer.css | 214 +++++++++ .../wwwroot/js/theme-customizer.js | 431 ++++++++++++++++++ Media.JoshHeaps.Net/wwwroot/js/theme.js | 57 ++- 11 files changed, 989 insertions(+), 135 deletions(-) create mode 100644 Media.JoshHeaps.Net/Api/ThemeApi.cs create mode 100644 Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql create mode 100644 Media.JoshHeaps.Net/Models/UserThemeOverrides.cs create mode 100644 Media.JoshHeaps.Net/Services/ThemeService.cs create mode 100644 Media.JoshHeaps.Net/wwwroot/css/profile.css create mode 100644 Media.JoshHeaps.Net/wwwroot/css/theme-customizer.css create mode 100644 Media.JoshHeaps.Net/wwwroot/js/theme-customizer.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c05911d..911bf40 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(tree:*)", "Bash(del Index.cshtml Index.cshtml.cs)", "Bash(dotnet build:*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(grep:*)", + "Bash(ls:*)" ], "deny": [], "ask": [] diff --git a/Media.JoshHeaps.Net/Api/ThemeApi.cs b/Media.JoshHeaps.Net/Api/ThemeApi.cs new file mode 100644 index 0000000..352be33 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/ThemeApi.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/theme")] +public partial class ThemeApi(ThemeService themeService) : ControllerBase +{ + private static readonly HashSet ValidCssVariables = + [ + "--bg-primary", "--bg-secondary", "--bg-tertiary", "--bg-hover", + "--text-primary", "--text-secondary", + "--border-primary", "--border-secondary", + "--accent-primary", "--accent-hover", + "--danger", "--danger-hover", "--success" + ]; + + [GeneratedRegex(@"^#[0-9a-fA-F]{6}$")] + private static partial Regex HexColorRegex(); + + [HttpGet("my")] + public async Task GetMyTheme() + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + + var theme = await themeService.GetUserThemeAsync(userId.Value); + if (theme == null) + { + return Ok(new { baseTheme = "light", colorOverrides = new Dictionary() }); + } + + return Ok(new { baseTheme = theme.BaseTheme, colorOverrides = theme.ColorOverrides }); + } + + [HttpPut("my")] + public async Task SaveMyTheme([FromBody] SaveThemeRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + + if (request.BaseTheme != "dark" && request.BaseTheme != "light") + return BadRequest("baseTheme must be 'dark' or 'light'"); + + foreach (var (key, value) in request.ColorOverrides) + { + if (!ValidCssVariables.Contains(key)) + return BadRequest($"Invalid CSS variable: {key}"); + if (!HexColorRegex().IsMatch(value)) + return BadRequest($"Invalid hex color for {key}: {value}"); + } + + await themeService.SaveUserThemeAsync(userId.Value, request.BaseTheme, request.ColorOverrides); + return Ok(); + } + + private long? GetUserIdFromAuth() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + return jwtUserId; + + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + return sessionUserId; + + return null; + } +} + +public record SaveThemeRequest(string BaseTheme, Dictionary ColorOverrides); diff --git a/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql b/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql new file mode 100644 index 0000000..d5039d7 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS app.user_theme_overrides ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + base_theme TEXT NOT NULL DEFAULT 'light', + color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) +); + +CREATE INDEX IF NOT EXISTS idx_uto_user_id ON app.user_theme_overrides(user_id); diff --git a/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs b/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs new file mode 100644 index 0000000..093845a --- /dev/null +++ b/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs @@ -0,0 +1,11 @@ +namespace Media.JoshHeaps.Net.Models; + +public class UserThemeOverrides +{ + public long Id { get; set; } + public long UserId { get; set; } + public string BaseTheme { get; set; } = "light"; + public Dictionary ColorOverrides { get; set; } = new(); + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Pages/Profile.cshtml b/Media.JoshHeaps.Net/Pages/Profile.cshtml index 18af864..58b263e 100644 --- a/Media.JoshHeaps.Net/Pages/Profile.cshtml +++ b/Media.JoshHeaps.Net/Pages/Profile.cshtml @@ -7,133 +7,8 @@ @section Styles { - + + }
@@ -176,5 +51,14 @@
+
+
+ Custom Colors + Personalize individual theme colors +
+ +
+ + diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index ee5d36a..0ea72d0 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -19,6 +19,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Add session support builder.Services.AddDistributedMemoryCache(); diff --git a/Media.JoshHeaps.Net/Services/ThemeService.cs b/Media.JoshHeaps.Net/Services/ThemeService.cs new file mode 100644 index 0000000..26472a7 --- /dev/null +++ b/Media.JoshHeaps.Net/Services/ThemeService.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Media.JoshHeaps.Net.Models; + +namespace Media.JoshHeaps.Net.Services; + +public class ThemeService(DbExecutor db) +{ + public async Task GetUserThemeAsync(long userId) + { + return await db.ExecuteReaderAsync( + @"SELECT id, user_id, base_theme, color_overrides::text, created_at, updated_at + FROM app.user_theme_overrides + WHERE user_id = @userId", + reader => + { + if (!reader.Read()) return null; + var overridesJson = reader.GetString(3); + return new UserThemeOverrides + { + Id = reader.GetInt64(0), + UserId = reader.GetInt64(1), + BaseTheme = reader.GetString(2), + ColorOverrides = JsonSerializer.Deserialize>(overridesJson) ?? new(), + CreatedAt = reader.GetDateTime(4), + UpdatedAt = reader.GetDateTime(5) + }; + }, + new { userId }); + } + + public async Task SaveUserThemeAsync(long userId, string baseTheme, Dictionary colorOverrides) + { + var overridesJson = JsonSerializer.Serialize(colorOverrides); + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.user_theme_overrides (user_id, base_theme, color_overrides, created_at, updated_at) + VALUES (@userId, @baseTheme, @overridesJson::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) DO UPDATE + SET base_theme = @baseTheme, + color_overrides = @overridesJson::jsonb, + updated_at = CURRENT_TIMESTAMP", + new { userId, baseTheme, overridesJson }); + } +} diff --git a/Media.JoshHeaps.Net/wwwroot/css/profile.css b/Media.JoshHeaps.Net/wwwroot/css/profile.css new file mode 100644 index 0000000..96367b3 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/css/profile.css @@ -0,0 +1,140 @@ +.profile-container { + max-width: 800px; + margin: 40px auto; + padding: 0 20px; +} + +.profile-section { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 24px; + margin-bottom: 20px; +} + +.profile-section h2 { + margin: 0 0 20px 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + border-bottom: 1px solid var(--border-primary); + padding-bottom: 12px; +} + +.profile-field { + margin-bottom: 16px; +} + +.profile-field label { + display: block; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 4px; + font-size: 14px; +} + +.profile-field-value { + color: var(--text-primary); + font-size: 15px; +} + +.theme-toggle-container { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; +} + +.theme-toggle-label { + display: flex; + flex-direction: column; +} + +.theme-toggle-label strong { + color: var(--text-primary); + font-size: 15px; + margin-bottom: 4px; +} + +.theme-toggle-label span { + color: var(--text-secondary); + font-size: 13px; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 26px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + transition: 0.3s; + border-radius: 34px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: var(--text-secondary); + transition: 0.3s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: var(--accent-primary); + border-color: var(--accent-primary); +} + +input:checked + .toggle-slider:before { + transform: translateX(24px); + background-color: var(--bg-primary); +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accent-primary); + text-decoration: none; + font-size: 14px; + margin-bottom: 20px; + transition: color 0.2s ease; +} + +.back-link:hover { + color: var(--accent-hover); +} + +.customize-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + color: var(--text-primary); + padding: 8px 16px; + font-size: 14px; + cursor: pointer; + transition: background 0.15s; +} + +.customize-btn:hover { + background: var(--bg-hover); +} diff --git a/Media.JoshHeaps.Net/wwwroot/css/theme-customizer.css b/Media.JoshHeaps.Net/wwwroot/css/theme-customizer.css new file mode 100644 index 0000000..24db616 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/css/theme-customizer.css @@ -0,0 +1,214 @@ +#theme-customizer-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +#theme-customizer-modal { + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 8px; + width: 90vw; + max-width: 1200px; + height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-bottom: 1px solid var(--border-primary); + flex-shrink: 0; +} + +.tc-header h3 { + margin: 0; + font-size: 18px; + color: var(--text-primary); +} + +.tc-close-btn { + background: none; + border: none; + font-size: 24px; + color: var(--text-secondary); + cursor: pointer; + line-height: 1; +} + +.tc-close-btn:hover { + color: var(--text-primary); +} + +.tc-controls { + padding: 12px 20px; + border-bottom: 1px solid var(--border-primary); + flex-shrink: 0; +} + +.tc-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.tc-row:last-child { + margin-bottom: 0; +} + +.tc-row label { + min-width: 110px; + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; +} + +.tc-row select, +.tc-row input[type="text"] { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 4px; + color: var(--text-primary); + padding: 6px 10px; + font-size: 14px; +} + +.tc-row select { + flex: 1; + max-width: 280px; +} + +.tc-picker-row input[type="color"] { + width: 40px; + height: 34px; + border: 1px solid var(--border-primary); + border-radius: 4px; + cursor: pointer; + padding: 2px; + background: var(--bg-secondary); +} + +.tc-picker-row input[type="text"] { + width: 90px; + font-family: monospace; +} + +.tc-swatches { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 8px 0 4px; +} + +.tc-swatch { + width: 28px; + height: 28px; + border-radius: 4px; + border: 2px solid var(--border-secondary); + cursor: pointer; + transition: border-color 0.15s; +} + +.tc-swatch:hover { + border-color: var(--text-secondary); +} + +.tc-swatch-overridden { + border-color: var(--accent-primary); + border-width: 2px; +} + +.tc-swatch-selected { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +.tc-btn-row { + display: flex; + gap: 10px; + margin-top: 8px; +} + +.tc-btn { + padding: 8px 16px; + border-radius: 6px; + border: 1px solid var(--border-primary); + font-size: 14px; + cursor: pointer; + background: var(--bg-secondary); + color: var(--text-primary); + transition: background 0.15s; +} + +.tc-btn:hover { + background: var(--bg-hover); +} + +.tc-btn-save { + background: var(--accent-primary); + color: #fff; + border-color: var(--accent-primary); +} + +.tc-btn-save:hover { + background: var(--accent-hover); +} + +.tc-btn-reset { + margin-left: auto; + color: var(--danger); + border-color: var(--danger); +} + +.tc-btn-reset:hover { + background: var(--danger); + color: #fff; +} + +.tc-btn-small { + padding: 4px 10px; + font-size: 12px; +} + +.tc-preview { + flex: 1; + min-height: 0; + padding: 0 20px 20px; + display: flex; + flex-direction: column; +} + +.tc-preview iframe { + width: 100%; + flex: 1; + min-height: 0; + border: 1px solid var(--border-primary); + border-radius: 4px; +} + +@media (max-width: 600px) { + #theme-customizer-modal { + width: 98vw; + height: 98vh; + } + + .tc-row { + flex-wrap: wrap; + } + + .tc-row label { + min-width: auto; + } +} diff --git a/Media.JoshHeaps.Net/wwwroot/js/theme-customizer.js b/Media.JoshHeaps.Net/wwwroot/js/theme-customizer.js new file mode 100644 index 0000000..4425ce4 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/theme-customizer.js @@ -0,0 +1,431 @@ +// Theme Customizer - Color picker modal with live iframe preview +(function() { + var COLOR_VARIABLES = [ + { group: 'Background', vars: [ + { key: '--bg-primary', name: 'Primary Background' }, + { key: '--bg-secondary', name: 'Secondary Background' }, + { key: '--bg-tertiary', name: 'Tertiary Background' }, + { key: '--bg-hover', name: 'Hover Background' } + ]}, + { group: 'Text', vars: [ + { key: '--text-primary', name: 'Primary Text' }, + { key: '--text-secondary', name: 'Secondary Text' } + ]}, + { group: 'Border', vars: [ + { key: '--border-primary', name: 'Primary Border' }, + { key: '--border-secondary', name: 'Secondary Border' } + ]}, + { group: 'Accent', vars: [ + { key: '--accent-primary', name: 'Primary Accent' }, + { key: '--accent-hover', name: 'Accent Hover' } + ]}, + { group: 'Status', vars: [ + { key: '--danger', name: 'Danger' }, + { key: '--danger-hover', name: 'Danger Hover' }, + { key: '--success', name: 'Success' } + ]} + ]; + + var allVarKeys = []; + COLOR_VARIABLES.forEach(function(g) { + g.vars.forEach(function(v) { allVarKeys.push(v.key); }); + }); + + var pendingOverrides = {}; + var savedOverrides = {}; + var modal = null; + var iframe = null; + + function getBaseTheme() { + return document.documentElement.getAttribute('data-theme') || 'light'; + } + + function getComputedColor(varName) { + return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + } + + function getCurrentColor(varName) { + if (pendingOverrides[varName]) return pendingOverrides[varName]; + // Get the base theme value (not the overridden inline value) + return getResolvedBaseColor(varName); + } + + function getResolvedBaseColor(varName) { + // To get the real CSS variable value without inline overrides, + // we temporarily remove the inline style, read computed, then restore + var inline = document.documentElement.style.getPropertyValue(varName); + if (inline) { + document.documentElement.style.removeProperty(varName); + var val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + document.documentElement.style.setProperty(varName, inline); + return val; + } + return getComputedColor(varName); + } + + function rgbToHex(rgb) { + if (!rgb || rgb.charAt(0) === '#') return rgb; + var match = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + if (!match) return rgb; + return '#' + [match[1], match[2], match[3]].map(function(x) { + return parseInt(x).toString(16).padStart(2, '0'); + }).join(''); + } + + function buildModal() { + var overlay = document.createElement('div'); + overlay.id = 'theme-customizer-overlay'; + + var container = document.createElement('div'); + container.id = 'theme-customizer-modal'; + + // Header + var header = document.createElement('div'); + header.className = 'tc-header'; + header.innerHTML = '

Customize Colors

'; + var closeBtn = document.createElement('button'); + closeBtn.className = 'tc-close-btn'; + closeBtn.textContent = '\u00D7'; + closeBtn.onclick = cancelCustomizer; + header.appendChild(closeBtn); + container.appendChild(header); + + // Controls area + var controls = document.createElement('div'); + controls.className = 'tc-controls'; + + // Base theme selector + var themeRow = document.createElement('div'); + themeRow.className = 'tc-row'; + themeRow.innerHTML = ''; + var themeSelect = document.createElement('select'); + themeSelect.id = 'tc-base-theme'; + themeSelect.innerHTML = ''; + themeSelect.value = getBaseTheme(); + themeSelect.onchange = function() { + document.documentElement.setAttribute('data-theme', themeSelect.value); + localStorage.setItem(window._themeUtils.THEME_KEY, themeSelect.value); + var toggle = document.getElementById('theme-toggle'); + if (toggle) toggle.checked = themeSelect.value === 'dark'; + // Re-apply pending overrides to parent + applyPendingToDocument(); + updatePickerForSelection(); + updateSwatches(); + applyToIframe(); + }; + themeRow.appendChild(themeSelect); + controls.appendChild(themeRow); + + // Color variable selector + var varRow = document.createElement('div'); + varRow.className = 'tc-row'; + varRow.innerHTML = ''; + var varSelect = document.createElement('select'); + varSelect.id = 'tc-var-select'; + COLOR_VARIABLES.forEach(function(group) { + var optgroup = document.createElement('optgroup'); + optgroup.label = group.group; + group.vars.forEach(function(v) { + var opt = document.createElement('option'); + opt.value = v.key; + opt.textContent = v.name; + optgroup.appendChild(opt); + }); + varSelect.appendChild(optgroup); + }); + varSelect.onchange = function() { updatePickerForSelection(); }; + varRow.appendChild(varSelect); + controls.appendChild(varRow); + + // Color picker row + var pickerRow = document.createElement('div'); + pickerRow.className = 'tc-row tc-picker-row'; + var colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.id = 'tc-color-picker'; + var hexInput = document.createElement('input'); + hexInput.type = 'text'; + hexInput.id = 'tc-hex-input'; + hexInput.placeholder = '#000000'; + hexInput.maxLength = 7; + var removeBtn = document.createElement('button'); + removeBtn.id = 'tc-remove-override'; + removeBtn.className = 'tc-btn tc-btn-small'; + removeBtn.textContent = 'Reset'; + removeBtn.title = 'Remove override for this variable'; + removeBtn.onclick = function() { + var key = varSelect.value; + delete pendingOverrides[key]; + applyPendingToDocument(); + updatePickerForSelection(); + updateSwatches(); + applyToIframe(); + }; + + colorInput.addEventListener('input', function() { + var key = varSelect.value; + pendingOverrides[key] = colorInput.value; + hexInput.value = colorInput.value; + applyPendingToDocument(); + updateSwatches(); + applyToIframe(); + }); + hexInput.addEventListener('input', function() { + var val = hexInput.value; + if (/^#[0-9a-fA-F]{6}$/.test(val)) { + var key = varSelect.value; + pendingOverrides[key] = val; + colorInput.value = val; + applyPendingToDocument(); + updateSwatches(); + applyToIframe(); + } + }); + + pickerRow.appendChild(colorInput); + pickerRow.appendChild(hexInput); + pickerRow.appendChild(removeBtn); + controls.appendChild(pickerRow); + + // Swatch strip + var swatchContainer = document.createElement('div'); + swatchContainer.className = 'tc-swatches'; + swatchContainer.id = 'tc-swatches'; + controls.appendChild(swatchContainer); + + // Buttons + var btnRow = document.createElement('div'); + btnRow.className = 'tc-btn-row'; + var saveBtn = document.createElement('button'); + saveBtn.className = 'tc-btn tc-btn-save'; + saveBtn.textContent = 'Save'; + saveBtn.onclick = saveCustomizer; + var cancelBtn = document.createElement('button'); + cancelBtn.className = 'tc-btn tc-btn-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = cancelCustomizer; + var resetBtn = document.createElement('button'); + resetBtn.className = 'tc-btn tc-btn-reset'; + resetBtn.textContent = 'Reset to Defaults'; + resetBtn.onclick = resetCustomizer; + btnRow.appendChild(saveBtn); + btnRow.appendChild(cancelBtn); + btnRow.appendChild(resetBtn); + controls.appendChild(btnRow); + + container.appendChild(controls); + + // Iframe preview + var previewContainer = document.createElement('div'); + previewContainer.className = 'tc-preview'; + iframe = document.createElement('iframe'); + iframe.id = 'theme-preview-iframe'; + iframe.src = '/'; + iframe.addEventListener('load', function() { applyToIframe(); }); + previewContainer.appendChild(iframe); + container.appendChild(previewContainer); + + overlay.appendChild(container); + return overlay; + } + + function updatePickerForSelection() { + var varSelect = document.getElementById('tc-var-select'); + var colorInput = document.getElementById('tc-color-picker'); + var hexInput = document.getElementById('tc-hex-input'); + var removeBtn = document.getElementById('tc-remove-override'); + if (!varSelect || !colorInput || !hexInput) return; + + var key = varSelect.value; + var color = getCurrentColor(key); + var hex = rgbToHex(color); + if (!hex || hex.charAt(0) !== '#') hex = '#000000'; + + colorInput.value = hex; + hexInput.value = hex; + removeBtn.style.display = pendingOverrides[key] ? 'inline-block' : 'none'; + } + + function updateSwatches() { + var container = document.getElementById('tc-swatches'); + var varSelect = document.getElementById('tc-var-select'); + if (!container) return; + container.innerHTML = ''; + + allVarKeys.forEach(function(key) { + var swatch = document.createElement('div'); + swatch.className = 'tc-swatch'; + if (pendingOverrides[key]) swatch.classList.add('tc-swatch-overridden'); + if (varSelect && varSelect.value === key) swatch.classList.add('tc-swatch-selected'); + + var color = pendingOverrides[key] || rgbToHex(getResolvedBaseColor(key)); + swatch.style.backgroundColor = color; + swatch.title = key + ': ' + color; + swatch.onclick = function() { + if (varSelect) { + varSelect.value = key; + updatePickerForSelection(); + } + }; + container.appendChild(swatch); + }); + } + + function applyPendingToDocument() { + // First clear all overrides + allVarKeys.forEach(function(key) { + document.documentElement.style.removeProperty(key); + }); + // Apply pending + for (var key in pendingOverrides) { + if (pendingOverrides.hasOwnProperty(key)) { + document.documentElement.style.setProperty(key, pendingOverrides[key]); + } + } + } + + function applyToIframe() { + if (!iframe || !iframe.contentDocument) return; + try { + var iframeRoot = iframe.contentDocument.documentElement; + var themeSelect = document.getElementById('tc-base-theme'); + var baseTheme = themeSelect ? themeSelect.value : getBaseTheme(); + iframeRoot.setAttribute('data-theme', baseTheme); + + // Clear previous overrides + allVarKeys.forEach(function(key) { + iframeRoot.style.removeProperty(key); + }); + // Apply pending + for (var key in pendingOverrides) { + if (pendingOverrides.hasOwnProperty(key)) { + iframeRoot.style.setProperty(key, pendingOverrides[key]); + } + } + } catch (e) { + // Cross-origin or not yet loaded + } + } + + function saveCustomizer() { + var themeSelect = document.getElementById('tc-base-theme'); + var baseTheme = themeSelect ? themeSelect.value : getBaseTheme(); + + // Save to localStorage + localStorage.setItem(window._themeUtils.OVERRIDES_KEY, JSON.stringify(pendingOverrides)); + localStorage.setItem(window._themeUtils.THEME_KEY, baseTheme); + + // Sync dark mode toggle + var toggle = document.getElementById('theme-toggle'); + if (toggle) toggle.checked = baseTheme === 'dark'; + + // Apply to document + document.documentElement.setAttribute('data-theme', baseTheme); + applyPendingToDocument(); + + savedOverrides = JSON.parse(JSON.stringify(pendingOverrides)); + + // Save to API + fetch('/api/theme/my', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseTheme: baseTheme, colorOverrides: pendingOverrides }) + }); + + closeModal(); + } + + function cancelCustomizer() { + // Restore from saved state + pendingOverrides = JSON.parse(JSON.stringify(savedOverrides)); + var savedTheme = localStorage.getItem(window._themeUtils.THEME_KEY) || 'light'; + document.documentElement.setAttribute('data-theme', savedTheme); + var toggle = document.getElementById('theme-toggle'); + if (toggle) toggle.checked = savedTheme === 'dark'; + + // Clear all inline overrides and re-apply saved + allVarKeys.forEach(function(key) { + document.documentElement.style.removeProperty(key); + }); + for (var key in savedOverrides) { + if (savedOverrides.hasOwnProperty(key)) { + document.documentElement.style.setProperty(key, savedOverrides[key]); + } + } + + closeModal(); + } + + function resetCustomizer() { + pendingOverrides = {}; + savedOverrides = {}; + + // Clear localStorage overrides + localStorage.removeItem(window._themeUtils.OVERRIDES_KEY); + + // Clear all inline style properties + allVarKeys.forEach(function(key) { + document.documentElement.style.removeProperty(key); + }); + + // Save to API + var baseTheme = getBaseTheme(); + fetch('/api/theme/my', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseTheme: baseTheme, colorOverrides: {} }) + }); + + closeModal(); + } + + function closeModal() { + if (modal) { + modal.remove(); + modal = null; + iframe = null; + } + } + + window.openThemeCustomizer = function() { + if (modal) return; + + // Load saved overrides from localStorage + var raw = localStorage.getItem(window._themeUtils.OVERRIDES_KEY); + try { + savedOverrides = raw ? JSON.parse(raw) : {}; + } catch (e) { + savedOverrides = {}; + } + pendingOverrides = JSON.parse(JSON.stringify(savedOverrides)); + + modal = buildModal(); + document.body.appendChild(modal); + + updatePickerForSelection(); + updateSwatches(); + + // Also fetch from API to sync + fetch('/api/theme/my') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data && data.colorOverrides && Object.keys(data.colorOverrides).length > 0) { + // If API has overrides and localStorage doesn't, sync + if (Object.keys(savedOverrides).length === 0) { + savedOverrides = data.colorOverrides; + pendingOverrides = JSON.parse(JSON.stringify(data.colorOverrides)); + localStorage.setItem(window._themeUtils.OVERRIDES_KEY, JSON.stringify(data.colorOverrides)); + applyPendingToDocument(); + updatePickerForSelection(); + updateSwatches(); + applyToIframe(); + } + } + if (data && data.baseTheme) { + var themeSelect = document.getElementById('tc-base-theme'); + if (themeSelect) themeSelect.value = data.baseTheme; + } + }) + .catch(function() {}); + }; +})(); diff --git a/Media.JoshHeaps.Net/wwwroot/js/theme.js b/Media.JoshHeaps.Net/wwwroot/js/theme.js index b59e698..056dbb3 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/theme.js +++ b/Media.JoshHeaps.Net/wwwroot/js/theme.js @@ -1,6 +1,7 @@ // Theme Toggle Functionality (function() { const THEME_KEY = 'theme-preference'; + const OVERRIDES_KEY = 'theme-overrides'; // Initialize theme on page load function initTheme() { @@ -8,31 +9,73 @@ const theme = savedTheme || 'light'; // Default to light document.documentElement.setAttribute('data-theme', theme); + } - // Update toggle if it exists - const toggle = document.getElementById('theme-toggle'); - if (toggle) { - toggle.checked = theme === 'dark'; + // Apply color overrides from localStorage + function applyColorOverrides() { + var raw = localStorage.getItem(OVERRIDES_KEY); + if (!raw) return; + try { + var overrides = JSON.parse(raw); + for (var key in overrides) { + if (overrides.hasOwnProperty(key)) { + document.documentElement.style.setProperty(key, overrides[key]); + } + } + } catch (e) { + // Ignore malformed JSON + } + } + + // Clear all inline color overrides from documentElement + function clearColorOverrides() { + var raw = localStorage.getItem(OVERRIDES_KEY); + if (!raw) return; + try { + var overrides = JSON.parse(raw); + for (var key in overrides) { + if (overrides.hasOwnProperty(key)) { + document.documentElement.style.removeProperty(key); + } + } + } catch (e) { + // Ignore } } // Toggle theme function toggleTheme() { - const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; - const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + var currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; + var newTheme = currentTheme === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', newTheme); localStorage.setItem(THEME_KEY, newTheme); + + // Re-apply overrides after theme switch (inline styles take precedence) + applyColorOverrides(); } // Initialize immediately (before DOMContentLoaded to prevent flash) initTheme(); + applyColorOverrides(); // Set up toggle listener when DOM is ready document.addEventListener('DOMContentLoaded', function() { - const toggle = document.getElementById('theme-toggle'); + var toggle = document.getElementById('theme-toggle'); if (toggle) { + // Sync checkbox state to current theme + var currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; + toggle.checked = currentTheme === 'dark'; + toggle.addEventListener('change', toggleTheme); } }); + + // Expose for theme customizer + window._themeUtils = { + applyColorOverrides: applyColorOverrides, + clearColorOverrides: clearColorOverrides, + THEME_KEY: THEME_KEY, + OVERRIDES_KEY: OVERRIDES_KEY + }; })();