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

View File

@@ -0,0 +1,453 @@
// Logo Customization System
(function() {
// Inject modal HTML
injectModal('logo-modal', `<div id="logo-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 400px; max-width: 520px;">
<h3>Dashboard Settings</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
Customize your dashboard's appearance and system preferences.
</p>
<div class="mb-16">
<label for="dashboard-title" class="form-label-bold">Dashboard Title:</label>
<input type="text" id="dashboard-title" placeholder="DashCaddy" maxlength="50" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg); color: var(--fg); font-size: 1rem;" />
<p class="tiny-hint">Shown in browser tab and header (max 50 characters)</p>
</div>
<hr class="hr-divider" />
<div class="mb-16">
<label class="form-label-bold">Logo:</label>
<p class="tiny-hint" style="margin-top: 2px;">Separate logos for dark and light themes, or use the same for both.</p>
</div>
<div style="display: flex; gap: 12px; margin-bottom: 12px;">
<div style="flex: 1; text-align: center; padding: 16px 10px; border-radius: 8px; background: #1a1a2e; border: 1px solid rgba(255,255,255,.1);">
<img id="logo-preview-dark" src="/assets/dashcaddy-logo-dark.png" alt="Dark theme logo" style="max-height: 60px; max-width: 100%;" />
<p style="font-size: 0.65rem; color: #9aa6bf; margin-top: 6px;">Dark themes</p>
</div>
<div style="flex: 1; text-align: center; padding: 16px 10px; border-radius: 8px; background: #f0f0f0; border: 1px solid rgba(0,0,0,.1);">
<img id="logo-preview-light" src="/assets/dashcaddy-logo-light.png" alt="Light theme logo" style="max-height: 60px; max-width: 100%;" />
<p style="font-size: 0.65rem; color: #5f6b7a; margin-top: 6px;">Light themes</p>
</div>
</div>
<p id="logo-status" style="font-size: 0.75rem; color: var(--muted); margin-bottom: 12px; text-align: center;">Using default logos</p>
<div class="mb-16">
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.82rem; cursor: pointer; user-select: none;">
<input type="checkbox" id="logo-same-both" /> Use same logo for both
</label>
</div>
<div id="logo-dual-uploads" class="mb-16" style="display: flex; gap: 12px;">
<div style="flex: 1;">
<label for="logo-upload-dark" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Dark theme logo:</label>
<input type="file" id="logo-upload-dark" accept="image/*" class="input-card-alt" style="font-size: 0.75rem;" />
</div>
<div style="flex: 1;">
<label for="logo-upload-light" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Light theme logo:</label>
<input type="file" id="logo-upload-light" accept="image/*" class="input-card-alt" style="font-size: 0.75rem;" />
</div>
</div>
<div id="logo-single-upload" class="mb-16" style="display: none;">
<label for="logo-upload-single" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Upload logo:</label>
<input type="file" id="logo-upload-single" accept="image/*" class="input-card-alt" />
<p class="tiny-hint">This logo will be used on all themes</p>
</div>
<div class="mb-16">
<label style="display: block; margin-bottom: 8px;">Logo Position:</label>
<div id="logo-position-btns" class="flex-row-gap">
<button type="button" data-pos="left" class="logo-pos-btn btn-option">Left</button>
<button type="button" data-pos="center" class="logo-pos-btn btn-option">Center</button>
<button type="button" data-pos="right" class="logo-pos-btn btn-option">Right</button>
</div>
</div>
<hr class="hr-divider" />
<div class="mb-16">
<label class="form-label-bold">Favicon (Browser Tab Icon):</label>
<div id="favicon-preview-container" style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px; padding: 12px; border-radius: 6px; background: var(--card-bg);">
<img id="favicon-preview" src="/assets/dashcaddy-favicon.ico" alt="Current favicon" style="width: 32px; height: 32px; image-rendering: pixelated;" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect fill=%22%23667%22 width=%2232%22 height=%2232%22 rx=%224%22/></svg>'" />
<span id="favicon-status" class="text-tiny-muted">Using DashCaddy favicon</span>
</div>
<input type="file" id="favicon-upload" accept="image/png,image/svg+xml" class="input-card-alt" />
<p class="tiny-hint">Upload PNG or SVG - automatically converted to ICO</p>
</div>
<hr class="hr-divider" />
<div class="mb-16">
<label for="settings-timezone" class="form-label-bold">Timezone:</label>
<select id="settings-timezone" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg); color: var(--fg); font-size: 1rem;">
<!-- Populated by JS with IANA timezones -->
</select>
<p class="tiny-hint">Used by all deployed containers. Changes apply to new deployments.</p>
</div>
<div class="weather-modal-buttons">
<button id="logo-reset" style="background: color-mix(in srgb, #ef4444 20%, transparent); border-color: #ef4444; color: #ef4444;">Reset to Default</button>
<button id="logo-cancel">Cancel</button>
<button id="logo-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);
const logoModal = document.getElementById('logo-modal');
const previewDark = document.getElementById('logo-preview-dark');
const previewLight = document.getElementById('logo-preview-light');
const logoStatus = document.getElementById('logo-status');
const sameBothCheckbox = document.getElementById('logo-same-both');
const dualUploads = document.getElementById('logo-dual-uploads');
const singleUpload = document.getElementById('logo-single-upload');
const uploadDark = document.getElementById('logo-upload-dark');
const uploadLight = document.getElementById('logo-upload-light');
const uploadSingle = document.getElementById('logo-upload-single');
const brandLogoDark = document.querySelector('#brand .brand-logo-dark');
const brandLogoLight = document.querySelector('#brand .brand-logo-light');
const topRow = document.querySelector('.top-row');
const dashboardTitleInput = document.getElementById('dashboard-title');
const DEFAULT_TITLE = DC.NAME;
let pendingDarkData = null;
let pendingLightData = null;
let pendingSingleData = null;
let currentPosition = 'left';
let currentTitle = DEFAULT_TITLE;
// Toggle between dual and single upload mode
sameBothCheckbox?.addEventListener('change', () => {
if (sameBothCheckbox.checked) {
dualUploads.style.display = 'none';
singleUpload.style.display = '';
// Clear individual pending data
pendingDarkData = null;
pendingLightData = null;
} else {
dualUploads.style.display = 'flex';
singleUpload.style.display = 'none';
pendingSingleData = null;
}
});
// Read file as data URL helper
function readFileAsDataURL(file, callback) {
if (!file || !file.type.startsWith('image/')) {
showNotification('Please select an image file', 'warning');
return;
}
const reader = new FileReader();
reader.onload = (e) => callback(e.target.result);
reader.readAsDataURL(file);
}
// Upload handlers
uploadDark?.addEventListener('change', (e) => {
readFileAsDataURL(e.target.files[0], (data) => {
pendingDarkData = data;
previewDark.src = data;
logoStatus.textContent = 'New dark logo ready to save';
});
});
uploadLight?.addEventListener('change', (e) => {
readFileAsDataURL(e.target.files[0], (data) => {
pendingLightData = data;
previewLight.src = data;
logoStatus.textContent = 'New light logo ready to save';
});
});
uploadSingle?.addEventListener('change', (e) => {
readFileAsDataURL(e.target.files[0], (data) => {
pendingSingleData = data;
previewDark.src = data;
previewLight.src = data;
logoStatus.textContent = 'New logo ready to save (both themes)';
});
});
// Apply logo position
function applyLogoPosition(pos) {
topRow.setAttribute('data-logo-pos', pos);
document.querySelectorAll('.logo-pos-btn').forEach(btn => {
btn.style.background = btn.dataset.pos === pos ? 'var(--accent)' : 'var(--card-bg)';
btn.style.color = btn.dataset.pos === pos ? 'white' : 'var(--fg)';
});
}
// Apply dashboard title
function applyDashboardTitle(title) {
currentTitle = title || DEFAULT_TITLE;
document.title = currentTitle;
const headerTitle = document.querySelector('.dashboard-title');
if (headerTitle) headerTitle.textContent = currentTitle;
}
// Load custom logo, position, and title on startup
async function loadCustomLogo() {
try {
const response = await fetch('/api/v1/logo');
if (response.ok) {
const data = await response.json();
// Apply dark/light variants
if (data.customLogoDark) {
brandLogoDark.src = data.customLogoDark;
previewDark.src = data.customLogoDark;
}
if (data.customLogoLight) {
brandLogoLight.src = data.customLogoLight;
previewLight.src = data.customLogoLight;
}
// Legacy single-logo fallback
if (!data.customLogoDark && !data.customLogoLight && data.customLogo) {
brandLogoDark.src = data.customLogo;
brandLogoLight.src = data.customLogo;
previewDark.src = data.customLogo;
previewLight.src = data.customLogo;
}
if (!data.isDefault) {
logoStatus.textContent = 'Using custom logo';
}
if (data.position) {
currentPosition = data.position;
applyLogoPosition(data.position);
}
if (data.dashboardTitle) {
applyDashboardTitle(data.dashboardTitle);
}
}
} catch (error) {
console.warn('Could not load custom logo:', error.message);
}
}
// Position button handlers
document.querySelectorAll('.logo-pos-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentPosition = btn.dataset.pos;
applyLogoPosition(currentPosition);
});
});
// Open logo modal on brand click
document.getElementById('brand')?.addEventListener('click', () => {
pendingDarkData = null;
pendingLightData = null;
pendingSingleData = null;
if (uploadDark) uploadDark.value = '';
if (uploadLight) uploadLight.value = '';
if (uploadSingle) uploadSingle.value = '';
// Reset checkbox to dual mode
if (sameBothCheckbox) sameBothCheckbox.checked = false;
dualUploads.style.display = 'flex';
singleUpload.style.display = 'none';
// Reset previews to current header logos
previewDark.src = brandLogoDark.src;
previewLight.src = brandLogoLight.src;
const isCustom = brandLogoDark.src.includes('custom-logo') || brandLogoLight.src.includes('custom-logo');
logoStatus.textContent = isCustom ? 'Using custom logo' : 'Using default logos';
applyLogoPosition(currentPosition);
dashboardTitleInput.value = currentTitle;
logoModal.classList.add('show');
});
// Save logo, position, and title
document.getElementById('logo-save')?.addEventListener('click', async () => {
try {
const newTitle = dashboardTitleInput.value.trim() || DEFAULT_TITLE;
const payload = {
position: currentPosition,
dashboardTitle: newTitle
};
// Determine which logo data to send
if (sameBothCheckbox?.checked && pendingSingleData) {
// Single logo for both variants
payload.dataDark = pendingSingleData;
payload.dataLight = pendingSingleData;
} else {
if (pendingDarkData) payload.dataDark = pendingDarkData;
if (pendingLightData) payload.dataLight = pendingLightData;
}
const response = await secureFetch('/api/v1/logo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
const t = '?t=' + Date.now();
if (data.pathDark) {
brandLogoDark.src = data.pathDark + t;
previewDark.src = data.pathDark + t;
}
if (data.pathLight) {
brandLogoLight.src = data.pathLight + t;
previewLight.src = data.pathLight + t;
}
applyLogoPosition(currentPosition);
applyDashboardTitle(newTitle);
logoModal.classList.remove('show');
} else {
const error = await response.json();
showNotification('Failed to save: ' + error.error, 'error');
}
} catch (error) {
showNotification('Error saving: ' + error.message, 'error');
}
});
// Reset all branding to defaults
document.getElementById('logo-reset')?.addEventListener('click', async () => {
if (!confirm('Reset all branding to DashCaddy defaults?\n\nThis will reset the logo, favicon, title, and position.')) return;
try {
const logoResponse = await secureFetch('/api/v1/logo', { method: 'DELETE' });
if (logoResponse.ok) {
brandLogoDark.src = '/assets/dashcaddy-logo-dark.png';
brandLogoLight.src = '/assets/dashcaddy-logo-light.png';
previewDark.src = '/assets/dashcaddy-logo-dark.png';
previewLight.src = '/assets/dashcaddy-logo-light.png';
logoStatus.textContent = 'Using default logos';
pendingDarkData = null;
pendingLightData = null;
pendingSingleData = null;
dashboardTitleInput.value = DEFAULT_TITLE;
applyDashboardTitle(DEFAULT_TITLE);
currentPosition = 'left';
applyLogoPosition('left');
}
const faviconResponse = await secureFetch('/api/v1/favicon', { method: 'DELETE' });
if (faviconResponse.ok) {
const faviconLink = document.querySelector('link[rel="icon"]');
const faviconPreview = document.getElementById('favicon-preview');
const faviconStatus = document.getElementById('favicon-status');
if (faviconLink) faviconLink.href = '/assets/dashcaddy-favicon.ico?t=' + Date.now();
if (faviconPreview) faviconPreview.src = '/assets/dashcaddy-favicon.ico?t=' + Date.now();
if (faviconStatus) faviconStatus.textContent = 'Using DashCaddy favicon';
pendingFaviconData = null;
}
} catch (error) {
showNotification('Error resetting branding: ' + error.message, 'error');
}
});
wireModal(logoModal, document.getElementById('logo-cancel'));
// ===== FAVICON HANDLING =====
const faviconPreview = document.getElementById('favicon-preview');
const faviconStatus = document.getElementById('favicon-status');
const faviconUpload = document.getElementById('favicon-upload');
const faviconLink = document.querySelector('link[rel="icon"]') || document.createElement('link');
let pendingFaviconData = null;
if (!document.querySelector('link[rel="icon"]')) {
faviconLink.rel = 'icon';
faviconLink.href = '/assets/dashcaddy-favicon.ico';
document.head.appendChild(faviconLink);
}
async function loadCustomFavicon() {
try {
const response = await fetch('/api/v1/favicon');
if (response.ok) {
const data = await response.json();
if (data.customFavicon) {
faviconLink.href = data.customFavicon + '?t=' + Date.now();
faviconPreview.src = data.customFavicon + '?t=' + Date.now();
faviconStatus.textContent = 'Using custom favicon';
}
}
} catch (error) {
console.warn('Could not load custom favicon:', error.message);
}
}
faviconUpload?.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.match(/^image\/(png|svg\+xml)$/)) {
showNotification('Please select a PNG or SVG file', 'warning');
faviconUpload.value = '';
return;
}
const reader = new FileReader();
reader.onload = (event) => {
pendingFaviconData = event.target.result;
faviconPreview.src = pendingFaviconData;
faviconStatus.textContent = 'New favicon ready to save';
};
reader.readAsDataURL(file);
});
// Save favicon alongside logo save
document.getElementById('logo-save')?.addEventListener('click', async () => {
if (pendingFaviconData) {
try {
const response = await secureFetch('/api/v1/favicon', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: pendingFaviconData })
});
if (response.ok) {
const data = await response.json();
faviconLink.href = data.path + '?t=' + Date.now();
faviconPreview.src = data.path + '?t=' + Date.now();
faviconStatus.textContent = 'Using custom favicon';
pendingFaviconData = null;
} else {
const error = await response.json();
showNotification('Failed to save favicon: ' + error.error, 'error');
}
} catch (error) {
showNotification('Error saving favicon: ' + error.message, 'error');
}
}
});
loadCustomFavicon();
loadCustomLogo();
// ===== TIMEZONE SETTING =====
const settingsTzSelect = document.getElementById('settings-timezone');
if (settingsTzSelect) {
const observer = new MutationObserver(() => {
if (logoModal.classList.contains('show') && settingsTzSelect.options.length === 0) {
(async () => {
let currentTz;
try {
const res = await fetch('/api/v1/config');
if (res.ok) { const cfg = await res.json(); currentTz = cfg.timezone; }
} catch (e) { /* ignore */ }
window.populateTimezoneSelect(settingsTzSelect, currentTz);
})();
}
});
observer.observe(logoModal, { attributes: true, attributeFilter: ['class'] });
document.getElementById('logo-save')?.addEventListener('click', async () => {
const tz = settingsTzSelect.value;
if (!tz) return;
try {
const res = await fetch('/api/v1/config');
if (!res.ok) return;
const cfg = await res.json();
cfg.timezone = tz;
cfg.updatedAt = new Date().toISOString();
await secureFetch('/api/v1/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
});
} catch (e) {
console.warn('Failed to save timezone:', e.message);
}
});
}
})();