mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* feat(web): show which plugins use each font, warn before deleting one
The Fonts tab lists font files from the web process's own scan, and the
plugins that register fonts run in the display process, so the tab had no
way to say whether a font was in use before deleting it.
The display service now publishes {catalog key: [plugin ids]} to the
shared cache (font_usage_snapshot, src/font_usage.py), built from the
loaded plugins' FontManager.register_manager_font() registrations. A
daemon thread checks every 10 s and writes only when the usage changed
(plus a daily refresh so cache cleanup cannot expire it); it never raises.
Families, aliases (press_start, four_by_six, ...) and paths are resolved
through FontManager's catalog to the file stem the Fonts tab keys rows by;
fonts outside assets/fonts are left out. Unloading a plugin drops its
registrations (new FontManager.forget_manager_fonts).
GET /api/v3/fonts/catalog merges used_by into each row per request (the
5-minute scan cache is copied, never edited): a list of plugin ids, or
null when the display service has not reported. The tab shows a Used by
column ("unknown" / "-" / ids, rendered as text) and deleting an in-use
font names the plugins in the confirmation, from a fresh read. The server
still refuses only system fonts. Catalog fetches bypass the browser's
5-second API cache, which otherwise served the pre-delete list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: call forget_manager_fonts through a hasattr check pylint can follow
getattr(..., None) then callable() is fine at runtime, but pylint's E1102
("not callable") can't see through it, and Codacy fails the check on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
895 lines
34 KiB
HTML
895 lines
34 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;
|
|
|
|
// showNotification is implemented by the notification widget
|
|
// (widgets/notification.js). If it hasn't loaded, queue the message; the
|
|
// widget shows queued messages as soon as it registers.
|
|
if (typeof window.showNotification !== 'function') {
|
|
window.showNotification = function(message, type = 'info') {
|
|
const registry = window.LEDMatrixWidgets;
|
|
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
|
|
if (widget && typeof widget.show === 'function') {
|
|
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
|
|
}
|
|
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
|
|
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
|
|
};
|
|
}
|
|
|
|
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>';
|
|
|
|
// Only show notification if showNotification is available
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error loading font configuration', 'error');
|
|
} else if (typeof window.showNotification === 'function') {
|
|
window.showNotification('Error loading font configuration', 'error');
|
|
} else {
|
|
console.error('Error loading font configuration:', error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// BDF bitmap fonts cannot be rendered server-side — skip the API call
|
|
if (family.toLowerCase().endsWith('.bdf')) {
|
|
previewImage.style.display = 'none';
|
|
loadingText.style.display = 'block';
|
|
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
|
|
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>
|