Files
LEDMatrix/web_interface/templates/v3/partials/fonts.html
T
ChuckandClaude Opus 5 d1e821c625 fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)
* fix(web): harden, polish and optimize the web UI per the September 2026 audit

Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20).

Implementation integrity (P0)
- app.css now defines every utility class the templates and JS use,
  including .hidden, so the ~145 JS show/hide toggles work. Button reset,
  and base component rules (.btn, .form-control) wrapped in :where() so
  utility classes on the same element win. New static-audit test fails
  when a used utility class has no rule.

Accessibility
- Focus rings render (the old ring rule referenced undefined variables);
  one :focus-visible outline everywhere; skip link; labelled nav landmarks.
- Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap,
  Escape, focus return, applied to every modal.
- Named icon-only buttons and labelled ~70 form fields.
- Toasts announced once; errors persist >= 10s; one showNotification.
- Captive WiFi page: live region, timeouts, dark mode, 16px inputs.

Performance (Pi Zero 2 W)
- SSE streams and tab timers pause when hidden or off-tab; the display
  stream only runs while a preview is visible. app-shell.js deferred.
- Widget scripts served as one versioned bundle (/assets/widgets.js):
  52 -> 21 script tags, 66 -> 35 requests on first load.
- Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS
  1358 KB -> 291 KB on the wire. SSE untouched.

Theming and responsive
- File managers, form fields and Fonts upload on theme tokens; bare
  inputs themed in dark mode; no more white surfaces.
- No horizontal overflow at 375px on any tab; 44px touch targets on
  coarse pointers; reduced-motion respected; header title truncates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings on #568

- json-file-manager: focus-trap releases kept in a Map (no dynamic
  property access or delete; no value-returning forEach callback)
- notification / schedule-picker: style and day-label lookups via Map
- app.js: move the pending-queue assignment out of the expression
- diff_viewer / error_handler: named function declarations instead of
  arrow consts

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: check the OAuth widget ships in the widget bundle

base.html no longer tags widget scripts one by one; they load through
/assets/widgets.js. Assert the page requests the bundle and the bundle
contains google-oauth.js, which is what the test was protecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): address review feedback on #568

- widget bundle version fingerprints every file (name, mtime_ns, size)
- gzip fallback appends Accept-Encoding to an existing Vary header
- dialog helper: releasing a non-top dialog no longer moves focus out of
  the dialog the user is in
- labels: file-upload targets its file input; fallback config fields get
  label for/id pairs; native color input has a fallback name
- utility audit also reads class names inside bound :class expressions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): give the native color-picker input an accessible name

CodeRabbit flagged this on PR #568 as an outside-diff finding (never
posted inline, so it was missed in the round of fixes that addressed
the other 6 review comments). The <input type="color"> only carried a
title attribute; screen readers don't reliably announce title, and
there's no other label naming the control when showHexInput is false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings in app-shell.js

- drop the unused catch binding on the SSE JSON parse
- move the pending-notification queue assignment out of the expression

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): contain plugin widgets/ dir and bound style-editor retries

From CodeRabbit review on #568 (code that arrived with the main merge):
- serve_plugin_widget resolves widgets/ with resolve_under before
  resolving the manifest script under it, so a symlinked widgets
  directory can't become the containment base (CWE-22). New test.
- style-editor init stops polling after ~10s when the widget never
  registers and leaves the plain fallback fields in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:42:24 -04:00

801 lines
30 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-40 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</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;
}
console.log('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);
};
}
console.log('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 } });
});
}
console.log('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) {
console.log('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'))) {
console.log('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
const [catalogRes, tokensRes] = await Promise.all([
fetch(`/api/v3/fonts/catalog`),
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.fontTokens = tokensData.data.tokens || {};
// Update local variable references
fontCatalog = window.fontCatalog;
fontTokens = window.fontTokens;
// Update displays
updateAvailableFontsDisplay();
console.log('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);
}
}
}
function updateAvailableFontsDisplay() {
const container = document.getElementById('available-fonts');
if (!container) return;
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;
return { name, filename, displayName, fontType, fontPath, isSystem };
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
// Build list using DOM APIs to prevent XSS
container.innerHTML = '';
fontEntries.forEach(font => {
const row = document.createElement('div');
row.className = 'flex items-center justify-between py-1 border-b border-gray-700 last:border-0';
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate flex-1';
nameSpan.textContent = font.displayName;
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);
if (font.isSystem) {
const systemBadge = document.createElement('span');
systemBadge.className = 'text-gray-600 text-xs ml-2';
systemBadge.textContent = '[system]';
row.appendChild(systemBadge);
} else {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs ml-2';
deleteBtn.title = 'Delete font';
deleteBtn.textContent = '[delete]';
deleteBtn.dataset.fontName = font.name;
deleteBtn.addEventListener('click', function() {
deleteFont(this.dataset.fontName);
});
row.appendChild(deleteBtn);
}
container.appendChild(row);
});
}
async function deleteFont(fontFamily) {
if (!confirm(`Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`)) {
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;
}
console.log(`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>