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:
453
status/js/logo-customization.js
Normal file
453
status/js/logo-customization.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user