Initial commit: DashCaddy v1.0

Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
2026-03-05 02:26:12 -08:00
commit f61e85d9a7
337 changed files with 75282 additions and 0 deletions

637
status/js/theme-builder.js Normal file
View File

@@ -0,0 +1,637 @@
// ========== THEME PICKER + THEME BUILDER ==========
(function () {
var themeBeforeBuilder = null;
var editingSlug = null;
var advancedOverrides = {}; // tracks which advanced fields user manually changed
// Display names for built-in themes
var THEME_LABELS = {
dark: 'Dark',
light: 'Light',
blue: 'Blue',
black: 'Black',
nord: 'Nord',
dracula: 'Dracula',
'solarized-dark': 'Solarized Dark',
'solarized-light': 'Solarized Light',
taxi: 'Taxi',
ocean: 'Ocean',
};
// Color picker field definitions: [css-prop, label, section]
var FIELDS = [
['bg', 'Background', 'base'],
['card-base', 'Card', 'base'],
['fg', 'Text', 'base'],
['muted', 'Muted Text', 'base'],
['border', 'Border', 'base'],
['accent', 'Accent', 'accent'],
['accent-strong', 'Accent Strong', 'accent'],
['ok-bg', 'OK Background', 'status'],
['ok-fg', 'OK Text', 'status'],
['bad-bg', 'Error Bg', 'status'],
['bad-fg', 'Error Text', 'status'],
['dot-ok', 'Dot OK', 'status'],
['dot-bad', 'Dot Error', 'status'],
['uptime', 'Uptime Bar', 'status'],
['hover', 'Hover', 'advanced'],
['card-hover', 'Card Hover', 'advanced'],
['base', 'Tags/Badges', 'advanced'],
['fg-muted', 'Dim Text', 'advanced'],
['success', 'Success', 'advanced'],
['error', 'Error', 'advanced'],
['warning', 'Warning', 'advanced'],
];
// ─── Theme Cycle Button ───────────────────────────────
var themeBtn = document.getElementById('theme');
if (!themeBtn) return;
var themeLabel = document.getElementById('theme-label');
function getLabelFor(t) {
if (THEME_LABELS[t]) return THEME_LABELS[t];
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
if (userThemes[t]) return userThemes[t].name || t;
return t;
}
function updateLabel() {
if (themeLabel) themeLabel.textContent = getLabelFor(window.getActiveTheme());
}
themeBtn.addEventListener('click', function () {
var list = window.THEMES.slice();
var current = window.getActiveTheme();
var idx = list.indexOf(current);
var next = list[(idx + 1) % list.length];
window.applyTheme(next);
updateLabel();
});
updateLabel();
// ─── Theme Builder Modal ───────────────────────────────
function buildFieldsHTML() {
var sections = { base: 'Base Colors', accent: 'Accent', status: 'Status', advanced: 'Advanced (auto-derived)' };
var grouped = {};
FIELDS.forEach(function (f) {
if (!grouped[f[2]]) grouped[f[2]] = [];
grouped[f[2]].push(f);
});
var html = '';
Object.keys(sections).forEach(function (key) {
if (key === 'advanced') {
html += '<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors &#9660;</div>';
html += '<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">';
} else {
html += '<div class="theme-builder-section">';
}
html += '<div class="theme-builder-section-title">' + sections[key] + '</div>';
(grouped[key] || []).forEach(function (f) {
html += '<div class="theme-builder-row">' +
'<span class="theme-builder-label">' + f[1] + '</span>' +
'<input type="color" class="theme-builder-color" data-prop="' + f[0] + '"' + (key === 'advanced' ? ' data-advanced="1"' : '') + ' value="#000000" />' +
'<span class="theme-builder-hex" data-hex="' + f[0] + '">#000000</span>' +
'</div>';
});
html += '</div>';
});
return html;
}
function buildStartFromOptions() {
return window.THEMES.map(function (t) {
return '<option value="' + t + '">' + getLabelFor(t) + '</option>';
}).join('');
}
var modalHTML = '<div id="theme-builder-modal" class="weather-modal">' +
'<div class="weather-modal-content" style="min-width:420px;max-width:560px;">' +
'<h3>Theme Builder</h3>' +
// Edit existing user themes dropdown
'<div id="theme-builder-existing" style="margin-bottom:16px;display:none;">' +
'<label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label>' +
'<select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;">' +
'<option value="">— New Theme —</option>' +
'</select>' +
'<button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button>' +
'</div>' +
'<div style="margin-bottom:16px;">' +
'<label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label>' +
'<input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" ' +
'style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" />' +
'</div>' +
'<div style="margin-bottom:16px;display:flex;align-items:center;">' +
'<label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg">' +
'<input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />' +
'Light background (use dark logo)</label>' +
'</div>' +
'<div style="margin-bottom:16px;">' +
'<label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label>' +
'<select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">' +
buildStartFromOptions() +
'</select>' +
'</div>' +
'<div class="theme-builder-preview" id="theme-builder-preview">' +
'<div class="theme-builder-card" id="theme-preview-card">' +
'<div class="preview-title">Sample Card</div>' +
'<div class="preview-muted">Secondary text preview</div>' +
'<div class="preview-badges">' +
'<span class="preview-badge" id="preview-badge-ok">ON</span>' +
'<span class="preview-badge" id="preview-badge-bad">OFF</span>' +
'</div>' +
'<div class="preview-dots">' +
'<span><span class="preview-dot" id="preview-dot-ok"></span> Online</span>' +
'<span><span class="preview-dot" id="preview-dot-bad"></span> Offline</span>' +
'</div>' +
'<button class="preview-btn" id="preview-accent-btn">Accent Button</button>' +
'</div>' +
'</div>' +
buildFieldsHTML() +
'<div class="weather-modal-buttons">' +
'<button id="theme-builder-cancel">Cancel</button>' +
'<button id="theme-builder-import" style="margin-left:auto;" title="Import theme from JSON file">Import</button>' +
'<button id="theme-builder-export" title="Export theme as JSON file">Export</button>' +
'<button id="theme-builder-save" class="btn-accent">Save Theme</button>' +
'</div>' +
'</div></div>';
injectModal('theme-builder-modal', modalHTML);
var modal = document.getElementById('theme-builder-modal');
var startSelect = document.getElementById('theme-builder-start');
var nameInput = document.getElementById('theme-builder-name');
var editSelect = document.getElementById('theme-builder-edit-select');
var existingSection = document.getElementById('theme-builder-existing');
var lightBgCheck = document.getElementById('theme-builder-lightbg');
var pickers = modal.querySelectorAll('.theme-builder-color');
var advancedSection = document.getElementById('theme-builder-advanced');
var advancedToggle = document.getElementById('theme-builder-advanced-toggle');
var exportBtn = document.getElementById('theme-builder-export');
// ─── Advanced toggle ───────────────────────────────
var advancedVisible = false;
advancedToggle.addEventListener('click', function () {
advancedVisible = !advancedVisible;
advancedSection.style.display = advancedVisible ? '' : 'none';
advancedToggle.innerHTML = advancedVisible
? 'Hide advanced colors &#9650;'
: 'Show advanced colors &#9660;';
});
// ─── Color picker helpers ───────────────────────────────
function getCurrentColors() {
var colors = {};
pickers.forEach(function (p) {
colors[p.dataset.prop] = p.value;
});
colors['card-bg'] = colors['card-base'];
return colors;
}
function getBaseColors() {
var colors = {};
pickers.forEach(function (p) {
if (!p.dataset.advanced) colors[p.dataset.prop] = p.value;
});
colors['card-bg'] = colors['card-base'];
if (lightBgCheck.checked) colors.lightBg = true;
return colors;
}
function updateAdvancedFromDerived() {
var base = getBaseColors();
var derived = window.deriveExtendedColors ? window.deriveExtendedColors(base) : {};
pickers.forEach(function (p) {
if (p.dataset.advanced && !advancedOverrides[p.dataset.prop]) {
var val = derived[p.dataset.prop] || '#333333';
p.value = val;
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
if (hex) hex.textContent = val.toUpperCase();
}
});
}
function loadThemeIntoPickers(themeName) {
var colors = window.THEME_COLORS[themeName];
if (!colors) return;
advancedOverrides = {};
pickers.forEach(function (p) {
var val = colors[p.dataset.prop] || '#000000';
if (val.startsWith('rgba') || val.startsWith('color-mix')) val = '#333333';
p.value = val;
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
if (hex) hex.textContent = val.toUpperCase();
});
lightBgCheck.checked = !!colors.lightBg;
updatePreview();
}
function loadUserThemeIntoPickers(slug) {
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
var theme = userThemes[slug];
if (!theme) return;
advancedOverrides = {};
pickers.forEach(function (p) {
var val = theme[p.dataset.prop] || '#000000';
p.value = val;
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
if (hex) hex.textContent = val.toUpperCase();
// If theme has explicit advanced values, mark as overridden
if (p.dataset.advanced && theme[p.dataset.prop]) {
advancedOverrides[p.dataset.prop] = true;
}
});
nameInput.value = theme.name || '';
lightBgCheck.checked = !!theme.lightBg;
// Fill any missing advanced fields from derivation
updateAdvancedFromDerived();
updatePreview();
}
function updatePreview() {
var c = getCurrentColors();
var preview = document.getElementById('theme-builder-preview');
var card = document.getElementById('theme-preview-card');
var title = card.querySelector('.preview-title');
var muted = card.querySelector('.preview-muted');
var badgeOk = document.getElementById('preview-badge-ok');
var badgeBad = document.getElementById('preview-badge-bad');
var dotOk = document.getElementById('preview-dot-ok');
var dotBad = document.getElementById('preview-dot-bad');
var btn = document.getElementById('preview-accent-btn');
var dots = card.querySelector('.preview-dots');
preview.style.background = c['bg'];
card.style.background = c['card-base'];
card.style.borderColor = c['border'];
title.style.color = c['fg'];
muted.style.color = c['muted'];
badgeOk.style.background = c['ok-bg'];
badgeOk.style.color = c['ok-fg'];
badgeBad.style.background = c['bad-bg'];
badgeBad.style.color = c['bad-fg'];
dotOk.style.background = c['dot-ok'];
dotBad.style.background = c['dot-bad'];
dots.style.color = c['fg'];
btn.style.background = c['accent'];
btn.style.color = c['bg'];
}
function applyColorsLive(colors) {
// Include derived colors for full live preview
var derived = window.deriveExtendedColors ? window.deriveExtendedColors(colors) : {};
window.THEME_PROPS.forEach(function (prop) {
var val = colors[prop] || derived[prop];
if (val) {
document.documentElement.style.setProperty('--' + prop, val);
}
});
}
// Wire up color pickers
pickers.forEach(function (p) {
p.addEventListener('input', function () {
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
if (hex) hex.textContent = p.value.toUpperCase();
// Track manual advanced overrides
if (p.dataset.advanced) {
advancedOverrides[p.dataset.prop] = true;
} else {
// Base color changed — re-derive advanced values (unless overridden)
updateAdvancedFromDerived();
}
updatePreview();
applyColorsLive(getCurrentColors());
});
});
// Light-bg checkbox should re-derive advanced colors
lightBgCheck.addEventListener('change', function () {
updateAdvancedFromDerived();
updatePreview();
applyColorsLive(getCurrentColors());
});
startSelect.addEventListener('change', function () {
advancedOverrides = {};
loadThemeIntoPickers(startSelect.value);
applyColorsLive(getCurrentColors());
});
// Populate and refresh the edit-existing dropdown
function refreshEditDropdown() {
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
var slugs = Object.keys(userThemes);
editSelect.innerHTML = '<option value="">— New Theme —</option>';
slugs.forEach(function (slug) {
var opt = document.createElement('option');
opt.value = slug;
opt.textContent = userThemes[slug].name || slug;
editSelect.appendChild(opt);
});
existingSection.style.display = slugs.length ? '' : 'none';
}
// Also refresh "Start from" options
function refreshStartFrom() {
startSelect.innerHTML = buildStartFromOptions();
}
// Edit dropdown change
editSelect.addEventListener('change', function () {
var slug = this.value;
if (slug) {
editingSlug = slug;
loadUserThemeIntoPickers(slug);
applyColorsLive(getCurrentColors());
} else {
editingSlug = null;
advancedOverrides = {};
nameInput.value = '';
startSelect.value = window.getActiveTheme();
loadThemeIntoPickers(startSelect.value);
}
exportBtn.style.display = editingSlug ? '' : 'none';
});
function openBuilder() {
themeBeforeBuilder = window.getActiveTheme();
editingSlug = null;
advancedOverrides = {};
// Reset advanced section to collapsed
advancedVisible = false;
advancedSection.style.display = 'none';
advancedToggle.innerHTML = 'Show advanced colors &#9660;';
refreshEditDropdown();
refreshStartFrom();
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
if (userThemes[themeBeforeBuilder]) {
// Active theme is a user theme — open it for editing
editSelect.value = themeBeforeBuilder;
editingSlug = themeBeforeBuilder;
loadUserThemeIntoPickers(themeBeforeBuilder);
} else {
editSelect.value = '';
nameInput.value = '';
startSelect.value = themeBeforeBuilder;
loadThemeIntoPickers(themeBeforeBuilder);
}
exportBtn.style.display = editingSlug ? '' : 'none';
modal.classList.add('show');
}
var customizeBtn = document.getElementById('theme-customize-btn');
if (customizeBtn) {
customizeBtn.addEventListener('click', function () { openBuilder(); });
}
// ─── Save ───────────────────────────────
document.getElementById('theme-builder-save').addEventListener('click', function () {
var colors = getCurrentColors();
var name = nameInput.value.trim();
if (!name) {
showNotification('Please enter a theme name', 'warning', 3000);
nameInput.focus();
return;
}
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
var slug;
var oldSlug = null;
if (editingSlug) {
slug = editingSlug;
// If name changed, re-slugify and potentially rename
var newSlug = window.slugifyThemeName(name, editingSlug);
if (newSlug !== editingSlug) {
oldSlug = editingSlug;
delete userThemes[editingSlug];
var idx = window.THEMES.indexOf(editingSlug);
if (idx !== -1) window.THEMES.splice(idx, 1);
delete window.THEME_COLORS[editingSlug];
slug = newSlug;
}
} else {
slug = window.slugifyThemeName(name);
}
// Build theme data with ALL color properties
var themeData = { name: name };
if (lightBgCheck.checked) themeData.lightBg = true;
window.THEME_PROPS.forEach(function (p) {
if (colors[p]) themeData[p] = colors[p];
});
// Update localStorage cache immediately for instant UI
userThemes[slug] = themeData;
safeSet(window.USER_THEMES_KEY, JSON.stringify(userThemes));
// Clear inline preview properties
window.clearCustomProperties();
// Re-inject CSS and apply
window.injectUserThemeStyles();
window.applyTheme(slug);
modal.classList.remove('show');
updateLabel();
// Save to server (source of truth)
var apiColors = {};
window.THEME_PROPS.forEach(function (p) { if (colors[p]) apiColors[p] = colors[p]; });
// If slug changed, delete the old one from server
if (oldSlug) {
secureFetch('/api/v1/themes/' + oldSlug, { method: 'DELETE' }).catch(function () {});
}
secureFetch('/api/v1/themes/' + slug, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, colors: apiColors, lightBg: lightBgCheck.checked })
}).then(function () {
showNotification(name + ' theme saved', 'success', 3000);
}).catch(function () {
showNotification(name + ' theme saved locally (server sync failed)', 'warning', 3000);
});
});
// ─── Cancel ───────────────────────────────
document.getElementById('theme-builder-cancel').addEventListener('click', function () {
modal.classList.remove('show');
window.clearCustomProperties();
if (themeBeforeBuilder) {
window.applyTheme(themeBeforeBuilder);
}
});
// ─── Delete ───────────────────────────────
document.getElementById('theme-builder-delete').addEventListener('click', function () {
if (!editingSlug) return;
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
var name = userThemes[editingSlug] ? userThemes[editingSlug].name : editingSlug;
if (!confirm('Delete "' + name + '" theme?')) return;
var slugToDelete = editingSlug;
// Update localStorage cache immediately
delete userThemes[slugToDelete];
safeSet(window.USER_THEMES_KEY, JSON.stringify(userThemes));
// Remove from runtime
var idx = window.THEMES.indexOf(slugToDelete);
if (idx !== -1) window.THEMES.splice(idx, 1);
delete window.THEME_COLORS[slugToDelete];
window.clearCustomProperties();
window.injectUserThemeStyles();
var fallback = themeBeforeBuilder && themeBeforeBuilder !== slugToDelete ? themeBeforeBuilder : 'dark';
window.applyTheme(fallback);
editingSlug = null;
modal.classList.remove('show');
updateLabel();
// Delete from server
secureFetch('/api/v1/themes/' + slugToDelete, { method: 'DELETE' }).then(function () {
showNotification(name + ' theme deleted', 'success', 3000);
}).catch(function () {
showNotification(name + ' theme deleted locally (server sync failed)', 'warning', 3000);
});
});
// ─── Export ───────────────────────────────
document.getElementById('theme-builder-export').addEventListener('click', function () {
if (!editingSlug) {
showNotification('Save the theme first, then export', 'warning', 3000);
return;
}
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
var theme = userThemes[editingSlug];
if (!theme) return;
var exportData = {
_dashcaddy_theme: true,
version: '1.0',
exportDate: new Date().toISOString(),
slug: editingSlug,
name: theme.name,
lightBg: theme.lightBg || false,
colors: {}
};
window.THEME_PROPS.forEach(function (p) {
if (theme[p]) exportData.colors[p] = theme[p];
});
var blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = editingSlug + '-theme.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('Theme exported as ' + editingSlug + '-theme.json', 'success', 3000);
});
// ─── Import ───────────────────────────────
document.getElementById('theme-builder-import').addEventListener('click', function () {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = function (e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function (ev) {
try {
var data = JSON.parse(ev.target.result);
// Support both wrapped format and raw theme format
var themeName, themeColors, themeLightBg;
if (data._dashcaddy_theme && data.colors) {
// Wrapped export format
themeName = data.name || 'Imported';
themeColors = data.colors;
themeLightBg = data.lightBg || false;
} else if (data.name && (data.bg || data['card-base'])) {
// Raw theme JSON (same format as server files)
themeName = data.name;
themeColors = {};
window.THEME_PROPS.forEach(function (p) { if (data[p]) themeColors[p] = data[p]; });
themeLightBg = data.lightBg || false;
} else {
throw new Error('Not a valid DashCaddy theme file');
}
// Load into builder
nameInput.value = themeName;
lightBgCheck.checked = !!themeLightBg;
editingSlug = null;
editSelect.value = '';
advancedOverrides = {};
// Apply colors to pickers
pickers.forEach(function (p) {
var val = themeColors[p.dataset.prop] || '#000000';
p.value = val;
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
if (hex) hex.textContent = val.toUpperCase();
if (p.dataset.advanced && themeColors[p.dataset.prop]) {
advancedOverrides[p.dataset.prop] = true;
}
});
// Fill any missing advanced fields
updateAdvancedFromDerived();
updatePreview();
applyColorsLive(getCurrentColors());
exportBtn.style.display = 'none';
showNotification('"' + themeName + '" loaded into builder. Click Save to keep it.', 'success', 5000);
} catch (err) {
showNotification('Import failed: ' + err.message, 'error', 5000);
}
};
reader.readAsText(file);
};
input.click();
});
// ─── Modal close handling ───────────────────────────────
wireModal(modal);
modal.addEventListener('click', function (e) {
if (e.target === modal) {
window.clearCustomProperties();
if (themeBeforeBuilder) window.applyTheme(themeBeforeBuilder);
}
});
})();