Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
454 lines
19 KiB
JavaScript
454 lines
19 KiB
JavaScript
// 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);
|
|
}
|
|
});
|
|
}
|
|
})();
|