Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/static/css/tailwind.3e9d4c71.css

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion app/static/css/tailwind.6f9d70cc.css

This file was deleted.

24 changes: 24 additions & 0 deletions app/static/js/announce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// FM_announce(text) reads a short status of the tools ("Processing…", "Your
// file is ready…") to screen-reader users through the page's polite live
// region (#fm-live in base.html). Only text that is already on the screen is
// passed in. The text is set after a short pause, so the same message twice is
// announced twice, and it is cleared after a few seconds, so no stale "done"
// is left for the next run. FM_announce('') empties the region at once (the
// error paths call it, so a pending "Processing…" can't follow the error).
(function () {
let setTimer = null;
let clearTimer = null;
window.FM_announce = function (text) {
const region = document.getElementById('fm-live');
if (!region) return;
clearTimeout(setTimer);
clearTimeout(clearTimer);
region.textContent = '';
if (!text) return;
setTimer = setTimeout(function () {
region.textContent = text.trim();
clearTimer = setTimeout(function () { region.textContent = ''; }, 8000);
}, 50);
};
})();
41 changes: 27 additions & 14 deletions app/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,15 @@ function setMode(mode) {
btnCompress.setAttribute('aria-pressed', mode === 'compress' ? 'true' : 'false');

if (mode === 'convert') {
btnConvert.classList.add('bg-brand', 'text-white');
btnConvert.classList.add('bg-brand-dark', 'text-white');
btnConvert.classList.remove('text-gray-400');
btnCompress.classList.remove('bg-brand', 'text-white');
btnCompress.classList.remove('bg-brand-dark', 'text-white');
btnCompress.classList.add('text-gray-400');
convertBtn.textContent = (window.FM_I18N && window.FM_I18N.convert) || 'Convert';
} else {
btnCompress.classList.add('bg-brand', 'text-white');
btnCompress.classList.add('bg-brand-dark', 'text-white');
btnCompress.classList.remove('text-gray-400');
btnConvert.classList.remove('bg-brand', 'text-white');
btnConvert.classList.remove('bg-brand-dark', 'text-white');
btnConvert.classList.add('text-gray-400');
convertBtn.textContent = (window.FM_I18N && window.FM_I18N.compress) || 'Compress';
}
Expand Down Expand Up @@ -200,12 +200,14 @@ function syncCompressModeButtons() {
const tBtn = document.getElementById('cmode-target');
if (!qBtn || !tBtn) return;
const isQuality = compressMode === 'quality';
qBtn.classList.toggle('bg-brand', isQuality);
qBtn.classList.toggle('text-white', isQuality);
qBtn.classList.toggle('text-gray-200', !isQuality);
tBtn.classList.toggle('bg-brand', !isQuality);
tBtn.classList.toggle('text-white', !isQuality);
tBtn.classList.toggle('text-gray-200', isQuality);
for (const [btn, on] of [[qBtn, isQuality], [tBtn, !isQuality]]) {
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.classList.toggle('bg-brand-dark', on);
btn.classList.toggle('text-white', on);
btn.classList.toggle('bg-gray-800', !on);
btn.classList.toggle('hover:bg-gray-700', !on);
btn.classList.toggle('text-gray-200', !on);
}
}

function updateTargetSizeLabel() {
Expand Down Expand Up @@ -286,7 +288,7 @@ function renderFileList() {
name.textContent = file.name;

const size = document.createElement('span');
size.className = 'text-xs text-gray-500 shrink-0';
size.className = 'text-xs text-ink-faint shrink-0';
size.textContent = formatBytes(file.size);

// Batch convert: each file picks its own target — mixed-format
Expand All @@ -295,7 +297,7 @@ function renderFileList() {
if (isBatch && currentMode === 'convert') {
perRowSelect = document.createElement('select');
perRowSelect.className =
'per-row-target bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs shrink-0 focus:outline-none focus:border-brand transition-colors';
'per-row-target bg-gray-800 border border-gray-700 rounded px-2 py-1 text-base sm:text-xs shrink-0 focus:outline-none focus:ring-2 focus:ring-brand focus:border-brand transition-colors';
perRowSelect.setAttribute('aria-label', i18nText('ariaTargetFormatFor', 'Target format for {name}', { name: file.name }));
const ext = getExtension(file.name);
const targets = supportedConversions[ext] || [];
Expand Down Expand Up @@ -335,7 +337,7 @@ function renderFileList() {

const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'text-xs text-gray-600 hover:text-red-400 transition-colors shrink-0';
rm.className = 'text-xs text-ink-faint hover:text-red-400 transition-colors shrink-0';
rm.setAttribute('aria-label', i18nText('ariaRemoveFile', 'Remove {name}', { name: file.name }));
rm.textContent = '✕';
rm.addEventListener('click', e => {
Expand Down Expand Up @@ -1164,7 +1166,7 @@ async function submitForm() {
label.textContent = i18nText('downloadResultSize', 'Download Result ({size} MB)', { size: mb });
} else {
label.textContent = isBatch
? i18nText('downloadZip', 'Download ZIP ({count} files)', { count: selectedFiles.length })
? i18nText('downloadZip', 'Download ZIP ({count} files)', { count: zipCount(res) })
: i18nText('downloadResult', 'Download Result');
}
}
Expand All @@ -1177,6 +1179,12 @@ async function submitForm() {

// ── State helpers ─────────────────────────────────────────────────────────────

// Files in the batch ZIP: the succeeded ones, not all that were selected.
function zipCount(res) {
const ok = parseInt(res.headers.get('X-FileMorph-Batch-Succeeded'), 10);
return Number.isNaN(ok) ? selectedFiles.length : ok;
}

function showProgress(text) {
document.getElementById('progress-text').textContent = text;
document.getElementById('progress-section').classList.remove('hidden');
Expand All @@ -1188,6 +1196,7 @@ function showProgress(text) {
hideBatchSummary();
setConvertErrorUpsell(false);
document.getElementById('convert-btn').disabled = true;
window.FM_announce && window.FM_announce(text);
}

function showResult() {
Expand All @@ -1196,9 +1205,12 @@ function showResult() {
document.getElementById('error-section').classList.add('hidden');
document.getElementById('convert-btn').disabled = false;
setConvertErrorUpsell(false);
// Read out what the result card shows: download label, hints, batch summary.
window.FM_announce && window.FM_announce(document.getElementById('result-section').innerText);
}

function showError(msg, code) {
window.FM_announce && window.FM_announce('');
document.getElementById('error-text').textContent = msg;
document.getElementById('progress-section').classList.add('hidden');
document.getElementById('result-section').classList.add('hidden');
Expand All @@ -1208,6 +1220,7 @@ function showError(msg, code) {
}

function resetResultState() {
window.FM_announce && window.FM_announce('');
document.getElementById('progress-section').classList.add('hidden');
document.getElementById('result-section').classList.add('hidden');
document.getElementById('error-section').classList.add('hidden');
Expand Down
29 changes: 24 additions & 5 deletions app/static/js/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,40 @@
const initial = user.email[0].toUpperCase();
desktop.innerHTML = `
<div class="relative group">
<button class="w-8 h-8 rounded-full bg-brand flex items-center justify-center font-bold text-white text-xs">${escapeHtml(initial)}</button>
<div class="absolute right-0 top-10 w-52 bg-gray-900 border border-gray-700 rounded-xl shadow-xl py-2 invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all z-50">
<button type="button" aria-expanded="false" aria-label="${escapeHtml(user.email)}" class="w-8 h-8 rounded-full bg-brand-dark flex items-center justify-center font-bold text-white text-xs">${escapeHtml(initial)}</button>
<div class="absolute right-0 top-10 w-52 bg-gray-900 border border-gray-700 rounded-xl shadow-xl py-2 invisible group-hover:visible group-focus-within:visible opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-all z-50">
<div class="px-4 py-2 border-b border-gray-700">
<p class="text-xs text-gray-400 truncate">${escapeHtml(user.email)}</p>
<p class="text-xs text-brand capitalize">${escapeHtml(user.tier)}</p>
<p class="text-xs text-brand-light capitalize">${escapeHtml(user.tier)}</p>
</div>
<a href="${localePrefix}/dashboard" class="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white">${escapeHtml(lDashboard)}</a>
<button data-action="signout" class="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-800">${escapeHtml(lSignOut)}</button>
</div>
</div>`;
desktop.querySelector('[data-action="signout"]').addEventListener('click', () => window.FM.logout());
// The panel opens on hover and on keyboard focus (CSS). Escape hides it with
// focus back on the button (WCAG 1.4.13); leaving the menu resets it.
const group = desktop.querySelector('.group');
const avatar = group.querySelector('button');
const panel = avatar.nextElementSibling;
const setOpen = (open) => {
panel.classList.toggle('!invisible', !open);
panel.classList.toggle('!opacity-0', !open);
avatar.setAttribute('aria-expanded', open ? 'true' : 'false');
};
group.addEventListener('focusin', () => avatar.setAttribute('aria-expanded', panel.classList.contains('!invisible') ? 'false' : 'true'));
avatar.addEventListener('click', () => setOpen(true)); // reopens after Escape
group.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { setOpen(false); avatar.focus(); }
});
group.addEventListener('focusout', (e) => {
if (!group.contains(e.relatedTarget)) { panel.classList.remove('!invisible', '!opacity-0'); avatar.setAttribute('aria-expanded', 'false'); }
});
group.addEventListener('mouseleave', () => panel.classList.remove('!invisible', '!opacity-0'));
if (mobile) {
mobile.innerHTML = `
<a href="${localePrefix}/dashboard" class="block text-gray-400 hover:text-white transition-colors py-1">${escapeHtml(lDashboard)}</a>
<button data-action="signout-mobile" class="block text-red-400 hover:text-white py-1 text-left">${escapeHtml(lSignOut)}</button>`;
<a href="${localePrefix}/dashboard" class="block text-gray-400 hover:text-white transition-colors py-2.5">${escapeHtml(lDashboard)}</a>
<button data-action="signout-mobile" class="block text-red-400 hover:text-white py-2.5 text-left">${escapeHtml(lSignOut)}</button>`;
mobile.querySelector('[data-action="signout-mobile"]').addEventListener('click', () => window.FM.logout());
}
} else {
Expand Down
8 changes: 4 additions & 4 deletions app/static/js/cockpit-metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,18 @@
function card(label, value, series, color) {
return `
<div class="bg-gray-950 border border-gray-800 rounded-xl p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">${escapeHtml(label)}</p>
<p class="text-xs text-ink-faint uppercase tracking-wider">${escapeHtml(label)}</p>
<p class="text-3xl font-bold mt-1 ${color}">${value.toLocaleString()}</p>
<div class="mt-3">${sparkline(series, color === 'text-brand' ? '#6366f1' : color === 'text-emerald-400' ? '#34d399' : '#9ca3af')}</div>
<p class="text-[10px] text-gray-600 mt-1">last ${state.days} d</p>
<p class="text-[10px] text-ink-faint mt-1">last ${state.days} d</p>
</div>`;
}

function renderPairs(pairs) {
if (!pairs || pairs.length === 0) {
const label = (window.FM_I18N && window.FM_I18N.noConversionsYet) || 'No conversions yet.';
const empty = document.createElement('li');
empty.className = 'text-xs text-gray-500';
empty.className = 'text-xs text-ink-faint';
empty.textContent = label;
$('metrics-pairs').replaceChildren(empty);
return;
Expand All @@ -94,7 +94,7 @@
const hint = $('metrics-failure-hint');
if (rate === null || rate === undefined) {
el.textContent = '—';
el.className = 'text-3xl font-bold mt-1 text-gray-500';
el.className = 'text-3xl font-bold mt-1 text-ink-faint';
// Distinguish "no traffic" from "too little traffic to be meaningful".
// The endpoint returns null + a sample_size for both, so we read the
// count to pick the right user-facing copy.
Expand Down
8 changes: 4 additions & 4 deletions app/static/js/cockpit.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

const TIER_COLOR = {
free: 'text-gray-400',
pro: 'text-brand',
pro: 'text-brand-light',
business: 'text-emerald-400',
enterprise: 'text-yellow-400',
};
Expand Down Expand Up @@ -58,7 +58,7 @@
.map(
(c) => `
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">${escapeHtml(c.label)}</p>
<p class="text-xs text-ink-faint uppercase tracking-wider">${escapeHtml(c.label)}</p>
<p class="text-3xl font-bold mt-1 ${c.color}">${c.value.toLocaleString()}</p>
</div>`,
)
Expand Down Expand Up @@ -135,7 +135,7 @@
$('user-count').textContent = `(${state.total.toLocaleString()})`;
const tbody = $('users-tbody');
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="6" class="px-6 py-8 text-center text-gray-600">No users match these filters.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="px-6 py-8 text-center text-ink-faint">No users match these filters.</td></tr>';
return;
}
tbody.innerHTML = items
Expand All @@ -147,7 +147,7 @@
<td class="px-6 py-3 capitalize font-semibold ${TIER_COLOR[u.tier] || 'text-gray-300'}">${escapeHtml(u.tier)}</td>
<td class="px-6 py-3 capitalize ${ROLE_COLOR[u.role] || 'text-gray-300'}">${escapeHtml(u.role)}</td>
<td class="px-6 py-3">${u.is_active ? '<span class="text-emerald-400">✓</span>' : '<span class="text-red-400">✗</span>'}</td>
<td class="px-6 py-3 text-gray-500 text-xs">${u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '—'}</td>
<td class="px-6 py-3 text-ink-faint text-xs">${u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '—'}</td>
<td class="px-6 py-3 text-right space-x-2">
<button class="ck-edit px-2 py-1 text-xs rounded border border-gray-700 hover:border-brand">Edit</button>
<button class="ck-deact px-2 py-1 text-xs rounded border border-gray-700 hover:border-red-500 disabled:opacity-30" ${isSelf || !u.is_active ? 'disabled' : ''}>Deactivate</button>
Expand Down
4 changes: 2 additions & 2 deletions app/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ async function loadKeys() {
const list = document.getElementById('keys-list');
if (keys.length === 0) {
const empty = document.createElement('p');
empty.className = 'text-sm text-gray-500';
empty.className = 'text-sm text-ink-faint';
empty.textContent = _t('noApiKeys', 'No API keys yet. Create one above.');
list.replaceChildren(empty);
return;
Expand All @@ -119,7 +119,7 @@ async function loadKeys() {
return '<div class="flex items-center justify-between gap-3 bg-gray-800 rounded-xl px-4 py-3">' +
'<div class="min-w-0">' +
'<p class="text-sm font-medium truncate">' + escapeHtml(k.label) + '</p>' +
'<p class="text-xs text-gray-500">' + escapeHtml(labelCreated) + ' ' + new Date(k.created_at).toLocaleDateString(dateLocale) +
'<p class="text-xs text-ink-faint">' + escapeHtml(labelCreated) + ' ' + new Date(k.created_at).toLocaleDateString(dateLocale) +
(k.last_used_at ? ' \u00b7 ' + escapeHtml(labelLastUsed) + ' ' + new Date(k.last_used_at).toLocaleDateString(dateLocale) : ' \u00b7 ' + escapeHtml(labelNeverUsed)) + '</p>' +
'</div>' +
'<button data-revoke-id="' + escapeHtml(k.id) + '" class="shrink-0 text-xs text-red-400 hover:text-red-300 px-3 py-1.5 rounded-lg border border-red-900 hover:border-red-700 transition-colors">' + escapeHtml(labelRevoke) + '</button>' +
Expand Down
33 changes: 26 additions & 7 deletions app/static/js/forgot-password.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
document.addEventListener('DOMContentLoaded', function () {
document.getElementById('send-btn').addEventListener('click', doReset);
document.addEventListener('keydown', function (e) { if (e.key === 'Enter') doReset(); });
// Enter in the field sends — not Enter on a link or button (the skip link is
// the first Tab stop and would send an empty form).
document.addEventListener('keydown', function (e) { if (e.key === 'Enter' && e.target.matches('input')) doReset(); });
});

async function doReset() {
const btn = document.getElementById('send-btn');
const email = document.getElementById('email').value.trim();
const field = document.getElementById('email');
const err = document.getElementById('error-msg');
// Empty or malformed: the browser says so at the field (required, type=email).
if (!field.reportValidity()) return;
const label = btn.textContent;
err.classList.add('hidden');
btn.disabled = true;
btn.textContent = (window.FM_I18N && window.FM_I18N.sending) || 'Sending\u2026';
await fetch('/api/v1/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
let ok = false;
try {
const res = await fetch('/api/v1/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: field.value.trim() }),
});
ok = res.ok;
} catch (_) { /* network error: reported below */ }
if (!ok) {
// No "email sent" unless the server took the request.
err.textContent = (window.FM_I18N && window.FM_I18N.pdfToolError) || 'Something went wrong. Please try again.';
err.classList.remove('hidden');
btn.disabled = false;
btn.textContent = label;
return;
}
document.getElementById('form-section').classList.add('hidden');
document.getElementById('success-msg').classList.remove('hidden');
}
2 changes: 2 additions & 0 deletions app/static/js/format-filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@
input.focus();
});
window.addEventListener('hashchange', () => {
// A jump to an anchor (the skip link's #main-content) is not a search.
if (location.hash && !new URLSearchParams(location.hash.slice(1)).has('q')) return;
const q = readHash();
if (q !== input.value) {
input.value = q;
Expand Down
4 changes: 3 additions & 1 deletion app/static/js/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,7 @@ async function doLogin() {

document.addEventListener('DOMContentLoaded', function () {
document.getElementById('login-btn').addEventListener('click', doLogin);
document.addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); });
// Enter in a field signs in — not Enter on a link or button (the skip link is
// the first Tab stop and would send an empty form).
document.addEventListener('keydown', function (e) { if (e.key === 'Enter' && e.target.matches('input')) doLogin(); });
});
3 changes: 3 additions & 0 deletions app/static/js/pdf-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
};

function showError(msg, code, field) {
window.FM_announce && window.FM_announce('');
hide($('pdf-progress'));
// An error replaces a result still on screen from an earlier run.
hide($('pdf-result'));
Expand Down Expand Up @@ -472,6 +473,7 @@
setExtractLinkVisible(false);
hide($('pdf-result'));
show($('pdf-progress'));
window.FM_announce && window.FM_announce($('pdf-progress-text').textContent);

const fd = new FormData();
fd.append('file', selectedFile);
Expand Down Expand Up @@ -516,6 +518,7 @@
}

show($('pdf-result'));
window.FM_announce && window.FM_announce($('pdf-result').innerText);
} catch (_) {
showError(t('pdfToolError', 'Something went wrong. Please try again.'));
} finally {
Expand Down
4 changes: 2 additions & 2 deletions app/static/js/pricing-segment.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ document.addEventListener("DOMContentLoaded", () => {
panelGov.classList.toggle("hidden", dev);
segDev.setAttribute("aria-pressed", dev ? "true" : "false");
segGov.setAttribute("aria-pressed", dev ? "false" : "true");
segDev.classList.toggle("bg-brand", dev);
segDev.classList.toggle("bg-brand-dark", dev);
segDev.classList.toggle("text-white", dev);
segDev.classList.toggle("text-gray-400", !dev);
segGov.classList.toggle("bg-brand", !dev);
segGov.classList.toggle("bg-brand-dark", !dev);
segGov.classList.toggle("text-white", !dev);
segGov.classList.toggle("text-gray-400", dev);
}
Expand Down
Loading
Loading