mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety - Route plugin lookups (installed list, update, recorded version, config form, web UI pages) through the plugin manager's resolver so plugins in ledmatrix-<id> directories work. - Captive-portal detection also sees the nmcli fallback AP (cached). - WiFi monitor daemon re-reads wifi_config.json when its mtime changes. - Drop the AP check in disconnect_from_network that could never fire. - LED status file per WiFiManager; config path falls back to this checkout. - BDF font preview via src.common.bdf_font. - Asset uploads validate every file before saving; metadata and calendar credentials written atomically; no absolute path in the response; asset delete answers 400 for a missing body. - Coerce string booleans in plugin toggle, on-demand start and AP force. - SSE broadcaster clears its thread handle before exiting. - start.py log filter handles every exc_info form. - Cleanups: unused plugins/fonts partial work, duplicate backup catch-alls, raw-config error helper, update-route tidy, redundant imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): request BDF font previews now that the server renders them The Fonts tab skipped the preview request for .bdf files because the server used to refuse them; /fonts/preview now draws BDF with the shared loader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): take the update route's plugin directory from a directory listing CodeQL flagged the path built from the request's plugin_id (the id was already validated with safe_path_component, which CodeQL doesn't model; the same flow on main is alerts 738/739). The directory is now the entry of plugins_dir matched by name, so nothing built from user input reaches the filesystem; an id with nothing installed goes to the store manager, which reports it not found as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): read the blueprint's plugin_manager defensively in _plugin_directory _get_plugin_version now goes through _plugin_directory, which read api_v3.plugin_manager directly; the attribute exists only once the app sets it, so test_path_traversal_guards::test_a_real_manifest_is_read failed when run on its own (order-dependent in the full suite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
865 lines
33 KiB
HTML
865 lines
33 KiB
HTML
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-900">Font Management</h2>
|
|
<p class="mt-1 text-sm text-gray-600">Upload, preview and manage the fonts available to every plugin.</p>
|
|
</div>
|
|
|
|
<!-- Font System Overview -->
|
|
<div class="mb-8">
|
|
<!-- Available Fonts -->
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<h3 class="text-md font-medium text-gray-900 mb-3">Available Font Families</h3>
|
|
<div id="available-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-64 overflow-y-auto">
|
|
<div class="text-gray-400">Loading...</div>
|
|
</div>
|
|
<p class="text-sm text-gray-600 mt-2">All available font families in the system.
|
|
<span id="font-usage-note"></span></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Font Upload -->
|
|
<div class="bg-gray-50 rounded-lg p-4 mb-8">
|
|
<h3 class="text-md font-medium text-gray-900 mb-4">Upload Custom Fonts</h3>
|
|
<p class="text-sm text-gray-600 mb-4">Upload your own TTF, OTF, or BDF font files to use in your LED matrix display.</p>
|
|
|
|
<div class="font-upload-area" id="font-upload-area">
|
|
<div class="upload-dropzone" id="upload-dropzone">
|
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-3"></i>
|
|
<p class="text-gray-600">Drag and drop font files here, or click to select</p>
|
|
<p class="text-sm text-gray-500">Supports .ttf, .otf, and .bdf files</p>
|
|
<input type="file" id="font-file-input" accept=".ttf,.otf,.bdf" multiple style="display: none;">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="upload-form" id="upload-form" style="display: none;">
|
|
<div class="mt-4 p-4 bg-white rounded border">
|
|
<h4 class="text-sm font-medium text-gray-900 mb-3">Selected Files</h4>
|
|
<div id="selected-files" class="space-y-2 mb-4">
|
|
<!-- Files will be listed here -->
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="upload-font-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
|
|
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my-custom-font">
|
|
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores, hyphens)</p>
|
|
</div>
|
|
<div class="flex items-end">
|
|
<button type="button" id="upload-fonts-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2">
|
|
<i class="fas fa-upload mr-2"></i>Upload Fonts
|
|
</button>
|
|
<button type="button" id="cancel-upload-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 ml-2">
|
|
<i class="fas fa-times mr-2"></i>Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Progress -->
|
|
<div id="upload-progress" class="mt-4 hidden">
|
|
<div class="bg-white rounded p-4 border">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-medium">Uploading...</span>
|
|
<span id="upload-percent" class="text-sm">0%</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
<div id="upload-progress-bar" class="bg-blue-600 h-2 rounded-full" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Font Preview -->
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<h3 class="text-md font-medium text-gray-900 mb-4">Font Preview</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
|
|
<div>
|
|
<div id="font-preview-container" class="border border-gray-300 bg-black rounded p-4 min-h-[100px] flex items-center justify-center">
|
|
<img id="font-preview-image" alt="Font preview" class="max-w-full" style="display: none;">
|
|
<span id="font-preview-loading" class="text-gray-400">Select a font to preview</span>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label for="preview-text" class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label>
|
|
<input type="text" id="preview-text" value="Sample Text 123" class="form-control text-sm">
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label for="preview-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
|
|
<select id="preview-family" class="form-control text-sm">
|
|
<!-- Dynamically populated from font catalog -->
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="preview-size" class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
|
|
<select id="preview-size" class="form-control text-sm">
|
|
<option value="xs">XS (6px)</option>
|
|
<option value="sm">SM (8px)</option>
|
|
<option value="md">MD (10px)</option>
|
|
<option value="lg">LG (12px)</option>
|
|
<option value="xl">XL (14px)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button id="update-preview-btn" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2">
|
|
<i class="fas fa-eye mr-2"></i>Update Preview
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Prevent script from running multiple times when HTMX reloads content
|
|
(function() {
|
|
// If script has already loaded, just re-initialize the tab without redeclaring variables/functions
|
|
if (typeof window._fontsScriptLoaded !== 'undefined') {
|
|
// Script already loaded, just trigger initialization
|
|
setTimeout(function() {
|
|
if (typeof window.initializeFontsTab === 'function') {
|
|
window.initializeFontsTab();
|
|
}
|
|
}, 50);
|
|
return;
|
|
}
|
|
|
|
// Mark script as loaded
|
|
window._fontsScriptLoaded = true;
|
|
|
|
// Initialize global variables on window object
|
|
window.fontCatalog = window.fontCatalog || {};
|
|
window.fontTokens = window.fontTokens || {};
|
|
window.selectedFontFiles = window.selectedFontFiles || [];
|
|
|
|
// Create references that can be reassigned
|
|
var fontCatalog = window.fontCatalog;
|
|
var fontTokens = window.fontTokens;
|
|
var selectedFontFiles = window.selectedFontFiles;
|
|
|
|
// Retry counter for initialization
|
|
var initRetryCount = 0;
|
|
var MAX_INIT_RETRIES = 50; // 5 seconds max (50 * 100ms)
|
|
|
|
function initializeFontsTab() {
|
|
// Allow re-initialization on each HTMX content swap
|
|
// The window._fontsScriptLoaded guard prevents function redeclaration
|
|
const availableEl = document.getElementById('available-fonts');
|
|
|
|
if (!availableEl) {
|
|
initRetryCount++;
|
|
if (initRetryCount >= MAX_INIT_RETRIES) {
|
|
console.error('Fonts tab elements not found after max retries, giving up');
|
|
return;
|
|
}
|
|
debugLog('Fonts tab elements not found, retrying...', {
|
|
availableFonts: !!availableEl,
|
|
attempt: initRetryCount
|
|
});
|
|
setTimeout(initializeFontsTab, 100);
|
|
return;
|
|
}
|
|
|
|
// Reset retry counter on successful init
|
|
initRetryCount = 0;
|
|
|
|
debugLog('Initializing font management...');
|
|
initializeFontManagement();
|
|
|
|
// Event listeners (use event delegation or ensure elements exist)
|
|
const uploadDropzone = document.getElementById('upload-dropzone');
|
|
const fontFileInput = document.getElementById('font-file-input');
|
|
const uploadFontsBtn = document.getElementById('upload-fonts-btn');
|
|
const cancelUploadBtn = document.getElementById('cancel-upload-btn');
|
|
const updatePreviewBtn = document.getElementById('update-preview-btn');
|
|
|
|
if (uploadDropzone && fontFileInput) {
|
|
uploadDropzone.addEventListener('click', () => {
|
|
fontFileInput.click();
|
|
});
|
|
}
|
|
|
|
if (fontFileInput) {
|
|
fontFileInput.addEventListener('change', handleFileSelection);
|
|
}
|
|
|
|
if (uploadFontsBtn) {
|
|
uploadFontsBtn.addEventListener('click', uploadSelectedFonts);
|
|
}
|
|
|
|
if (cancelUploadBtn) {
|
|
cancelUploadBtn.addEventListener('click', cancelFontUpload);
|
|
}
|
|
|
|
|
|
if (updatePreviewBtn) {
|
|
updatePreviewBtn.addEventListener('click', updateFontPreview);
|
|
}
|
|
|
|
// Drag and drop for upload area
|
|
if (uploadDropzone) {
|
|
uploadDropzone.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
uploadDropzone.classList.add('drag-over');
|
|
});
|
|
|
|
uploadDropzone.addEventListener('dragleave', () => {
|
|
uploadDropzone.classList.remove('drag-over');
|
|
});
|
|
|
|
uploadDropzone.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
uploadDropzone.classList.remove('drag-over');
|
|
handleFileSelection({ target: { files: e.dataTransfer.files } });
|
|
});
|
|
}
|
|
|
|
debugLog('Fonts tab initialized successfully');
|
|
}
|
|
|
|
// Expose initializeFontsTab to window for re-initialization after HTMX reload
|
|
window.initializeFontsTab = initializeFontsTab;
|
|
|
|
// Initialize after HTMX content swap for dynamic loading
|
|
// Note: We don't use DOMContentLoaded here because this partial is loaded via HTMX
|
|
// after the main page's DOMContentLoaded has already fired
|
|
(function() {
|
|
// Function to initialize when fonts content is loaded
|
|
function tryInitializeFontsTab() {
|
|
const fontsContent = document.getElementById('fonts-content');
|
|
|
|
if (fontsContent) {
|
|
debugLog('Fonts content detected, initializing...');
|
|
setTimeout(() => {
|
|
initializeFontsTab();
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
// Set up HTMX event listener for when content is swapped
|
|
if (typeof document.body !== 'undefined') {
|
|
document.body.addEventListener('htmx:afterSettle', function(event) {
|
|
// Check if the event target is the fonts-content container or contains it
|
|
const target = event.target;
|
|
if (target && (target.id === 'fonts-content' || target.querySelector && target.querySelector('#fonts-content'))) {
|
|
debugLog('HTMX loaded fonts content, initializing...', target.id);
|
|
tryInitializeFontsTab();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Also check if content is already loaded (e.g., if script runs after HTMX swap)
|
|
// This handles the case where the script executes after HTMX has already swapped
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', tryInitializeFontsTab);
|
|
} else {
|
|
// DOM already loaded, check immediately
|
|
tryInitializeFontsTab();
|
|
}
|
|
})();
|
|
|
|
async function initializeFontManagement() {
|
|
try {
|
|
await loadFontData();
|
|
populateFontSelects();
|
|
updateFontPreview();
|
|
initializeFontUpload();
|
|
} catch (error) {
|
|
console.error('Error initializing font management:', error);
|
|
showNotification('Error loading font configuration', 'error');
|
|
}
|
|
}
|
|
|
|
async function loadFontData() {
|
|
const availableContainer = document.getElementById('available-fonts');
|
|
|
|
// Ensure containers exist before proceeding
|
|
if (!availableContainer) {
|
|
console.error('Font containers not found, cannot load font data');
|
|
return;
|
|
}
|
|
|
|
// Show loading states
|
|
availableContainer.innerHTML = '<div class="text-blue-400">Loading font data...</div>';
|
|
|
|
try {
|
|
// Use absolute URLs to ensure they work when loaded via HTMX
|
|
// no-store: /api/v3 GETs are browser-cacheable for 5 s, and this
|
|
// reload follows an upload or delete (or the usage check before one),
|
|
// so a cached copy would show the list as it was before the change.
|
|
const [catalogRes, tokensRes] = await Promise.all([
|
|
fetch(`/api/v3/fonts/catalog`, { cache: 'no-store' }),
|
|
fetch(`/api/v3/fonts/tokens`)
|
|
]);
|
|
|
|
// Check if all responses are successful
|
|
if (!catalogRes.ok || !tokensRes.ok) {
|
|
const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}`;
|
|
console.error('Font API error:', statusText);
|
|
throw new Error(`Failed to load font data: ${statusText}`);
|
|
}
|
|
|
|
const catalogData = await catalogRes.json();
|
|
const tokensData = await tokensRes.json();
|
|
|
|
// Validate response structure
|
|
if (!catalogData || !catalogData.data || !tokensData || !tokensData.data) {
|
|
console.error('Invalid font API response structure:', {
|
|
catalog: !!catalogData?.data,
|
|
tokens: !!tokensData?.data
|
|
});
|
|
throw new Error('Invalid response format from font API');
|
|
}
|
|
|
|
// Update both window properties and local references
|
|
window.fontCatalog = catalogData.data.catalog || {};
|
|
window.fontUsage = catalogData.data.font_usage || { available: false, generated_at: null };
|
|
window.fontTokens = tokensData.data.tokens || {};
|
|
|
|
// Update local variable references
|
|
fontCatalog = window.fontCatalog;
|
|
fontTokens = window.fontTokens;
|
|
|
|
// Update displays
|
|
updateAvailableFontsDisplay();
|
|
|
|
debugLog('Font data loaded successfully', {
|
|
catalogSize: Object.keys(fontCatalog).length,
|
|
tokensSize: Object.keys(fontTokens).length
|
|
});
|
|
} catch (error) {
|
|
console.error('Error loading font data:', error);
|
|
|
|
// Show error states
|
|
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
|
|
|
|
showNotification('Error loading font configuration', 'error');
|
|
}
|
|
}
|
|
|
|
// The plugin ids that use a font, as the catalog reports them: an array
|
|
// (possibly empty) when the display service has reported, null when not.
|
|
function fontUsedBy(fontInfo) {
|
|
if (!fontInfo || typeof fontInfo !== 'object') return null;
|
|
return Array.isArray(fontInfo.used_by)
|
|
? fontInfo.used_by.filter(id => typeof id === 'string')
|
|
: null;
|
|
}
|
|
|
|
function updateAvailableFontsDisplay() {
|
|
const container = document.getElementById('available-fonts');
|
|
if (!container) return;
|
|
|
|
const usage = window.fontUsage || {};
|
|
const note = document.getElementById('font-usage-note');
|
|
if (note) {
|
|
const reported = usage.generated_at
|
|
? ` (reported ${String(usage.generated_at).replace('T', ' ')})` : '';
|
|
note.textContent = usage.available
|
|
? `"Used by" lists the running plugins that registered the font with the display service${reported}. A plugin that opens a font file directly is not listed.`
|
|
: '"Used by" is unknown: the display service has not reported which fonts its plugins use. Start it, or reload this tab a few seconds after it starts.';
|
|
}
|
|
|
|
if (Object.keys(fontCatalog).length === 0) {
|
|
container.innerHTML = '<div class="text-gray-400">No fonts available</div>';
|
|
return;
|
|
}
|
|
|
|
const fontEntries = Object.entries(fontCatalog).map(([name, fontInfo]) => {
|
|
const fontPath = typeof fontInfo === 'string' ? fontInfo : (fontInfo?.path || '');
|
|
const filename = typeof fontInfo === 'object' ? (fontInfo.filename || name) : name;
|
|
const displayName = typeof fontInfo === 'object' ? (fontInfo.display_name || name) : name;
|
|
const fontType = typeof fontInfo === 'object' ? (fontInfo.type || '').toUpperCase() : '';
|
|
// Use is_system flag from API (single source of truth)
|
|
const isSystem = typeof fontInfo === 'object' ? (fontInfo.is_system === true) : false;
|
|
const usedBy = fontUsedBy(fontInfo);
|
|
return { name, filename, displayName, fontType, fontPath, isSystem, usedBy };
|
|
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
|
|
// Build list using DOM APIs to prevent XSS (plugin ids are untrusted too)
|
|
container.innerHTML = '';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'flex items-center gap-2 pb-1 mb-1 border-b border-gray-600 text-xs uppercase text-gray-400';
|
|
const fontHead = document.createElement('span');
|
|
fontHead.className = 'flex-1 min-w-0';
|
|
fontHead.textContent = 'Font';
|
|
const usedHead = document.createElement('span');
|
|
usedHead.className = 'flex-1 min-w-0';
|
|
usedHead.textContent = 'Used by';
|
|
const actionHead = document.createElement('span');
|
|
actionHead.className = 'w-24 flex-shrink-0';
|
|
header.append(fontHead, usedHead, actionHead);
|
|
container.appendChild(header);
|
|
|
|
fontEntries.forEach(font => {
|
|
const row = document.createElement('div');
|
|
row.className = 'flex items-start gap-2 py-1 border-b border-gray-700 last:border-0';
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'truncate flex-1 min-w-0';
|
|
nameSpan.textContent = font.displayName;
|
|
nameSpan.title = font.filename;
|
|
|
|
if (font.fontType) {
|
|
const typeSpan = document.createElement('span');
|
|
typeSpan.className = 'text-gray-500 ml-1';
|
|
typeSpan.textContent = `(${font.fontType})`;
|
|
nameSpan.appendChild(typeSpan);
|
|
}
|
|
|
|
row.appendChild(nameSpan);
|
|
|
|
// Wraps rather than truncates: the list is what a delete is weighed
|
|
// against, and a title tooltip is out of reach on a phone.
|
|
const usedSpan = document.createElement('span');
|
|
usedSpan.className = 'flex-1 min-w-0 break-words';
|
|
if (font.usedBy === null) {
|
|
usedSpan.classList.add('text-gray-500', 'italic');
|
|
usedSpan.textContent = 'unknown';
|
|
usedSpan.title = 'The display service has not reported which fonts its plugins use';
|
|
} else if (font.usedBy.length === 0) {
|
|
usedSpan.classList.add('text-gray-500');
|
|
usedSpan.textContent = '—';
|
|
usedSpan.title = 'No running plugin registered this font';
|
|
} else {
|
|
usedSpan.classList.add('text-gray-300');
|
|
usedSpan.textContent = font.usedBy.join(', ');
|
|
}
|
|
row.appendChild(usedSpan);
|
|
|
|
const actionCell = document.createElement('span');
|
|
actionCell.className = 'w-24 flex-shrink-0 text-right';
|
|
if (font.isSystem) {
|
|
const systemBadge = document.createElement('span');
|
|
systemBadge.className = 'text-gray-600 text-xs';
|
|
systemBadge.textContent = '[system]';
|
|
actionCell.appendChild(systemBadge);
|
|
} else {
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs';
|
|
deleteBtn.title = 'Delete font';
|
|
deleteBtn.textContent = '[delete]';
|
|
deleteBtn.dataset.fontName = font.name;
|
|
deleteBtn.addEventListener('click', function() {
|
|
deleteFont(this.dataset.fontName);
|
|
});
|
|
actionCell.appendChild(deleteBtn);
|
|
}
|
|
row.appendChild(actionCell);
|
|
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Which plugins use a font now. Re-reads the catalog so the warning reflects
|
|
// the display service's latest report rather than the one the tab opened
|
|
// with; falls back to that one if the request fails.
|
|
async function currentFontUsedBy(fontName) {
|
|
const own = (obj) => obj && typeof obj === 'object'
|
|
&& Object.prototype.hasOwnProperty.call(obj, fontName);
|
|
try {
|
|
const response = await fetch('/api/v3/fonts/catalog', { cache: 'no-store' });
|
|
if (response.ok) {
|
|
const catalog = (await response.json())?.data?.catalog;
|
|
if (own(catalog)) return fontUsedBy(catalog[fontName]);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not refresh font usage before delete:', error);
|
|
}
|
|
return own(fontCatalog) ? fontUsedBy(fontCatalog[fontName]) : null;
|
|
}
|
|
|
|
// The confirm() text for deleting a font. Plain text: confirm() renders no
|
|
// HTML, so a plugin id cannot inject markup here.
|
|
function deleteFontConfirmMessage(fontFamily, usedBy) {
|
|
if (Array.isArray(usedBy) && usedBy.length > 0) {
|
|
return `Font "${fontFamily}" is in use.\n\nUsed by: ${usedBy.join(', ')}.\n\n`
|
|
+ 'Delete anyway? This action cannot be undone.';
|
|
}
|
|
const base = `Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`;
|
|
if (usedBy === null) {
|
|
return `${base}\n\nWhich plugins use it is unknown: the display service has not reported.`;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
async function deleteFont(fontFamily) {
|
|
const usedBy = await currentFontUsedBy(fontFamily);
|
|
if (!confirm(deleteFontConfirmMessage(fontFamily, usedBy))) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v3/fonts/${encodeURIComponent(fontFamily)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
let message;
|
|
try {
|
|
const errorData = JSON.parse(text);
|
|
message = errorData.message || `Server error: ${response.status}`;
|
|
} catch {
|
|
message = `Server error: ${response.status}`;
|
|
}
|
|
showNotification(message, 'error');
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
showNotification(data.message || `Font "${fontFamily}" deleted successfully`, 'success');
|
|
// Refresh font data and UI
|
|
await loadFontData();
|
|
populateFontSelects();
|
|
// Clear font-selector widget cache if available
|
|
if (typeof window.clearFontSelectorCache === 'function') {
|
|
window.clearFontSelectorCache();
|
|
}
|
|
} else {
|
|
showNotification(data.message || `Failed to delete font "${fontFamily}"`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting font:', error);
|
|
showNotification(`Error deleting font: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function populateFontSelects() {
|
|
// Populate font family dropdowns from catalog
|
|
const previewSelect = document.getElementById('preview-family');
|
|
|
|
if (!previewSelect) return;
|
|
|
|
// Get font entries sorted by display name
|
|
const fontEntries = Object.entries(fontCatalog).map(([key, info]) => {
|
|
const filename = typeof info === 'object' ? (info.filename || key) : key;
|
|
const displayName = typeof info === 'object' ? (info.display_name || key) : key;
|
|
const fontType = typeof info === 'object' ? (info.type || 'unknown').toUpperCase() : '';
|
|
return { key, filename, displayName, fontType };
|
|
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
|
|
// Build options using DOM APIs to prevent XSS
|
|
// Clear preview select
|
|
previewSelect.innerHTML = '';
|
|
|
|
// Add font options to both selects
|
|
fontEntries.forEach(font => {
|
|
const typeLabel = font.fontType ? ` (${font.fontType})` : '';
|
|
|
|
const previewOpt = document.createElement('option');
|
|
previewOpt.value = font.filename;
|
|
previewOpt.textContent = font.displayName + typeLabel;
|
|
previewSelect.appendChild(previewOpt);
|
|
});
|
|
|
|
// Select first font in preview if available
|
|
if (fontEntries.length > 0) {
|
|
previewSelect.value = fontEntries[0].filename;
|
|
}
|
|
|
|
debugLog(`Populated font selects with ${fontEntries.length} fonts`);
|
|
}
|
|
|
|
async function updateFontPreview() {
|
|
const previewImage = document.getElementById('font-preview-image');
|
|
const loadingText = document.getElementById('font-preview-loading');
|
|
const textInput = document.getElementById('preview-text');
|
|
const familySelect = document.getElementById('preview-family');
|
|
const sizeSelect = document.getElementById('preview-size');
|
|
|
|
if (!previewImage || !loadingText) return;
|
|
|
|
const text = textInput?.value || 'Sample Text 123';
|
|
const family = familySelect?.value || '';
|
|
const sizeToken = sizeSelect?.value || 'md';
|
|
const sizePx = fontTokens[sizeToken] || 10;
|
|
|
|
if (!family) {
|
|
previewImage.style.display = 'none';
|
|
loadingText.style.display = 'block';
|
|
loadingText.textContent = 'Select a font to preview';
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
loadingText.textContent = 'Loading preview...';
|
|
loadingText.style.display = 'block';
|
|
previewImage.style.display = 'none';
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
font: family,
|
|
text: text,
|
|
size: sizePx,
|
|
bg: '000000',
|
|
fg: 'ffffff'
|
|
});
|
|
|
|
const response = await fetch(`/api/v3/fonts/preview?${params}`);
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
let message;
|
|
try {
|
|
const errorData = JSON.parse(text);
|
|
message = errorData.message || `Server error: ${response.status}`;
|
|
} catch {
|
|
message = `Server error: ${response.status}`;
|
|
}
|
|
loadingText.textContent = message;
|
|
loadingText.style.display = 'block';
|
|
previewImage.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success' && data.data?.image) {
|
|
previewImage.src = data.data.image;
|
|
previewImage.style.display = 'block';
|
|
loadingText.style.display = 'none';
|
|
} else {
|
|
loadingText.textContent = data.message || 'Failed to load preview';
|
|
loadingText.style.display = 'block';
|
|
previewImage.style.display = 'none';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading font preview:', error);
|
|
loadingText.textContent = 'Error loading preview';
|
|
loadingText.style.display = 'block';
|
|
previewImage.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function initializeFontUpload() {
|
|
// Setup already done in event listeners
|
|
}
|
|
|
|
function handleFileSelection(event) {
|
|
const files = Array.from(event.target.files);
|
|
const validExtensions = ['ttf', 'otf', 'bdf'];
|
|
const validFiles = files.filter(file => {
|
|
const extension = file.name.toLowerCase().split('.').pop();
|
|
return validExtensions.includes(extension);
|
|
});
|
|
|
|
if (validFiles.length === 0) {
|
|
showNotification('Please select valid .ttf, .otf, or .bdf font files', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (validFiles.length !== files.length) {
|
|
showNotification(`${files.length - validFiles.length} invalid files were ignored`, 'warning');
|
|
}
|
|
|
|
selectedFontFiles = validFiles;
|
|
showUploadForm();
|
|
}
|
|
|
|
function showUploadForm() {
|
|
if (selectedFontFiles.length === 0) return;
|
|
|
|
const uploadForm = document.getElementById('upload-form');
|
|
const selectedFilesContainer = document.getElementById('selected-files');
|
|
const fontFamilyInput = document.getElementById('upload-font-family');
|
|
|
|
// Show selected files using DOM APIs to prevent XSS
|
|
selectedFilesContainer.innerHTML = '';
|
|
selectedFontFiles.forEach(file => {
|
|
const row = document.createElement('div');
|
|
row.className = 'flex items-center justify-between p-2 bg-gray-100 rounded';
|
|
|
|
const span = document.createElement('span');
|
|
span.className = 'text-sm';
|
|
span.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
|
|
|
|
row.appendChild(span);
|
|
selectedFilesContainer.appendChild(row);
|
|
});
|
|
|
|
// Auto-generate font family name from first file
|
|
if (selectedFontFiles.length === 1) {
|
|
const filename = selectedFontFiles[0].name;
|
|
const nameWithoutExt = filename.substring(0, filename.lastIndexOf('.'));
|
|
// Preserve hyphens, convert other special chars to underscores
|
|
fontFamilyInput.value = nameWithoutExt.toLowerCase().replace(/[^a-z0-9-]/g, '_');
|
|
}
|
|
|
|
uploadForm.style.display = 'block';
|
|
uploadForm.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
function cancelFontUpload() {
|
|
selectedFontFiles = [];
|
|
document.getElementById('upload-form').style.display = 'none';
|
|
document.getElementById('font-file-input').value = '';
|
|
}
|
|
|
|
async function uploadSelectedFonts() {
|
|
if (selectedFontFiles.length === 0) {
|
|
showNotification('No files selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
const fontFamilyInput = document.getElementById('upload-font-family');
|
|
const fontFamily = fontFamilyInput.value.trim();
|
|
|
|
if (!fontFamily) {
|
|
showNotification('Please enter a font family name', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Validate font family name (must match backend validation)
|
|
if (!/^[a-z0-9_-]+$/i.test(fontFamily)) {
|
|
showNotification('Font family name can only contain letters, numbers, underscores, and hyphens', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showNotification('Uploading fonts...', 'info');
|
|
showUploadProgress();
|
|
|
|
for (let i = 0; i < selectedFontFiles.length; i++) {
|
|
const file = selectedFontFiles[i];
|
|
const formData = new FormData();
|
|
formData.append('font_file', file);
|
|
formData.append('font_family', i === 0 ? fontFamily : `${fontFamily}_${i + 1}`);
|
|
|
|
const response = await fetch(`/api/v3/fonts/upload`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
let message;
|
|
try {
|
|
const errorData = JSON.parse(text);
|
|
message = errorData.message || `Server error: ${response.status}`;
|
|
} catch {
|
|
message = `Server error: ${response.status}`;
|
|
}
|
|
showNotification(`Error uploading "${file.name}": ${message}`, 'error');
|
|
continue;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
showNotification(`Font "${data.font_family}" uploaded successfully`, 'success');
|
|
} else {
|
|
showNotification(`Error uploading "${file.name}": ${data.message}`, 'error');
|
|
}
|
|
|
|
// Update progress
|
|
const percent = ((i + 1) / selectedFontFiles.length) * 100;
|
|
updateUploadProgress(percent);
|
|
}
|
|
|
|
// Refresh font data and UI
|
|
await loadFontData();
|
|
populateFontSelects();
|
|
cancelFontUpload();
|
|
hideUploadProgress();
|
|
// Clear font-selector widget cache so new fonts appear in plugin configs
|
|
if (typeof window.clearFontSelectorCache === 'function') {
|
|
window.clearFontSelectorCache();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error uploading fonts:', error);
|
|
showNotification('Error uploading fonts: ' + error, 'error');
|
|
hideUploadProgress();
|
|
}
|
|
}
|
|
|
|
function showUploadProgress() {
|
|
document.getElementById('upload-progress').classList.remove('hidden');
|
|
}
|
|
|
|
function hideUploadProgress() {
|
|
document.getElementById('upload-progress').classList.add('hidden');
|
|
}
|
|
|
|
function updateUploadProgress(percent) {
|
|
document.getElementById('upload-percent').textContent = Math.round(percent) + '%';
|
|
document.getElementById('upload-progress-bar').style.width = percent + '%';
|
|
}
|
|
|
|
})(); // End of script load guard - prevents redeclaration on HTMX reload
|
|
</script>
|
|
|
|
<style>
|
|
.drag-over {
|
|
border-color: var(--color-primary, #3b82f6) !important;
|
|
background-color: var(--color-info-bg, #eff6ff) !important;
|
|
}
|
|
|
|
.font-upload-area {
|
|
margin: 15px 0;
|
|
}
|
|
|
|
.upload-dropzone {
|
|
border: 2px dashed var(--color-field-border, #d1d5db);
|
|
border-radius: 0.75rem;
|
|
padding: 2rem 1.5rem;
|
|
text-align: center;
|
|
background: var(--color-background, #f9fafb);
|
|
transition: border-color 0.15s ease, background-color 0.15s ease;
|
|
cursor: pointer;
|
|
}
|
|
|
|
@media (min-width: 640px) {
|
|
.upload-dropzone {
|
|
padding: 2.5rem;
|
|
}
|
|
}
|
|
|
|
.upload-dropzone:hover {
|
|
border-color: var(--color-primary, #3b82f6);
|
|
background: var(--color-info-bg, #eff6ff);
|
|
}
|
|
|
|
.upload-dropzone i {
|
|
font-size: 3rem;
|
|
color: var(--color-primary, #3b82f6);
|
|
margin-bottom: 0.75rem;
|
|
display: block;
|
|
}
|
|
|
|
.upload-dropzone p {
|
|
margin: 0.25rem 0;
|
|
font-size: 1rem;
|
|
color: var(--color-text-secondary, #374151);
|
|
}
|
|
|
|
.upload-hint {
|
|
font-size: 0.875rem !important;
|
|
color: var(--color-text-tertiary, #4b5563) !important;
|
|
font-style: italic;
|
|
}
|
|
|
|
.upload-form {
|
|
margin-top: 1.25rem;
|
|
padding: 1.25rem;
|
|
background: var(--color-background, #f9fafb);
|
|
border-radius: 0.5rem;
|
|
border: 1px solid var(--color-border, #d1d5db);
|
|
}
|
|
|
|
#font-preview-container {
|
|
max-width: 100%;
|
|
min-height: 100px;
|
|
}
|
|
|
|
#font-preview-image {
|
|
max-width: 100%;
|
|
height: auto;
|
|
}
|
|
</style>
|