516 lines
17 KiB
HTML
516 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>%%CROSSPOINT%% - Fonts</title>
|
|
<style>
|
|
:root {
|
|
--font-color: #333;
|
|
--bg: #f5f5f5;
|
|
--title-color: #2c3e50;
|
|
--card-bg: #FFF;
|
|
--label-color: #7f8c8d;
|
|
--border-color: #eee;
|
|
--accent-color: rgb(110, 154, 130);
|
|
--accent-hover-color: #5a8c73;
|
|
--danger-color: #e74c3c;
|
|
--danger-hover: #c0392b;
|
|
--notice-bg: #fff8e1;
|
|
--notice-border: #ffd54f;
|
|
--notice-color: #5d4e0a;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--font-color: #f5f5f5;
|
|
--bg: #333;
|
|
--title-color: #ecf0f1;
|
|
--card-bg: #444;
|
|
--label-color: #bdc3c7;
|
|
--border-color: #555;
|
|
--notice-bg: #4a4030;
|
|
--notice-border: #b89a3e;
|
|
--notice-color: #f3e5a0;
|
|
color-scheme: dark;
|
|
}
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
Oxygen, Ubuntu, sans-serif;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background-color: var(--bg);
|
|
color: var(--font-color);
|
|
}
|
|
h1 {
|
|
color: var(--title-color);
|
|
border-bottom: 2px solid var(--accent-color);
|
|
padding-bottom: 10px;
|
|
}
|
|
h2 { color: var(--title-color); margin-top: 0; }
|
|
h3 { margin: 0 0 8px 0; }
|
|
.card {
|
|
background: var(--card-bg);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin: 15px 0;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
.nav-links {
|
|
margin: 20px 0;
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.nav-links a {
|
|
padding: 10px 20px;
|
|
color: var(--font-color);
|
|
text-decoration: none;
|
|
border-radius: 4px;
|
|
}
|
|
.nav-links a.active {
|
|
background-color: var(--accent-color);
|
|
color: white;
|
|
}
|
|
.nav-links a:not(.active):hover {
|
|
background-color: var(--accent-hover-color);
|
|
color: white;
|
|
}
|
|
.family {
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding: 12px 0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.family:last-child { border-bottom: none; }
|
|
.family-info { flex: 1; }
|
|
.family-meta { color: var(--label-color); font-size: 0.9em; }
|
|
.btn {
|
|
padding: 6px 16px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
}
|
|
.btn-danger {
|
|
background: var(--danger-color);
|
|
color: white;
|
|
}
|
|
.btn-danger:hover { background: var(--danger-hover); }
|
|
.btn-primary {
|
|
background: var(--accent-color);
|
|
color: white;
|
|
}
|
|
.btn-primary:hover { background: var(--accent-hover-color); }
|
|
.btn[disabled] {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
.upload-form {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.upload-form input[type="text"] {
|
|
padding: 6px 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
background: var(--bg);
|
|
color: var(--font-color);
|
|
}
|
|
.upload-form input[type="file"] { flex: 1; min-width: 200px; }
|
|
#status {
|
|
margin-top: 10px;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
display: none;
|
|
}
|
|
.status-ok { background: #d4edda; color: #155724; display: block !important; }
|
|
.status-err { background: #f8d7da; color: #721c24; display: block !important; }
|
|
.empty { color: var(--label-color); text-align: center; padding: 20px; }
|
|
.notice {
|
|
background: var(--notice-bg);
|
|
border: 1px solid var(--notice-border);
|
|
color: var(--notice-color);
|
|
border-radius: 6px;
|
|
padding: 10px 14px;
|
|
margin: 0 0 12px;
|
|
font-size: 0.95em;
|
|
}
|
|
.notice a { color: inherit; font-weight: 600; }
|
|
.toolbar {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
.download-layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(240px, 1fr) 280px;
|
|
gap: 16px;
|
|
align-items: start;
|
|
}
|
|
.status-panel {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
}
|
|
.status-panel h3 {
|
|
margin: 0 0 10px 0;
|
|
font-size: 1rem;
|
|
}
|
|
.status-panel p {
|
|
margin: 0;
|
|
}
|
|
#remoteStatus {
|
|
margin-top: 8px;
|
|
min-height: 80px;
|
|
display: block;
|
|
border-radius: 6px;
|
|
padding: 12px;
|
|
background: var(--notice-bg);
|
|
border: 1px solid var(--notice-border);
|
|
color: var(--notice-color);
|
|
}
|
|
.status-ok {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
border-color: #c3e6cb;
|
|
}
|
|
.status-err {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
border-color: #f5c6cb;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>📚 %%CROSSPOINT%%</h1>
|
|
|
|
<div class="nav-links">
|
|
<a href="/files">File Manager</a>
|
|
<a href="/settings">Settings</a>
|
|
<a href="/fonts" class="active">Font Manager</a>
|
|
<a href="/stats">Reading Stats</a>
|
|
<a href="/systeminfo">System Info</a>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Installed Fonts</h2>
|
|
<div id="families"><p class="empty">Loading...</p></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Download Fonts</h2>
|
|
<div class="toolbar">
|
|
<button id="refreshManifest" class="btn btn-primary" type="button">Refresh Catalog</button>
|
|
<button id="downloadAllRemote" class="btn btn-primary" type="button">Download All Updates</button>
|
|
</div>
|
|
<div class="download-layout">
|
|
<div id="remoteFamilies"><p class="empty">Catalog not loaded</p></div>
|
|
<div class="status-panel">
|
|
<h3>Download Status</h3>
|
|
<div id="remoteStatus" class="status-message"><p>Waiting for action...</p></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Upload Font</h2>
|
|
<p class="notice">
|
|
Need a <code>.cpfont</code> file? Convert any TrueType/OpenType font at
|
|
<a href="https://crosspointreader.com/fonts" target="_blank" rel="noopener">crosspointreader.com/fonts</a>,
|
|
then upload the resulting files here.
|
|
</p>
|
|
<form class="upload-form" id="uploadForm">
|
|
<input type="file" id="fontFiles" accept=".cpfont" multiple required />
|
|
<button type="submit" class="btn btn-primary">Upload</button>
|
|
</form>
|
|
<p id="pickedInfo" class="family-meta" style="margin: 8px 0 0;"></p>
|
|
<div id="status"></div>
|
|
</div>
|
|
|
|
<script>
|
|
function formatSize(bytes) {
|
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
|
return bytes + ' B';
|
|
}
|
|
|
|
function setStatus(id, text, ok) {
|
|
const el = document.getElementById(id);
|
|
el.className = ok === undefined ? '' : (ok ? 'status-ok' : 'status-err');
|
|
el.style.display = 'block';
|
|
el.textContent = text;
|
|
}
|
|
|
|
async function loadFonts() {
|
|
try {
|
|
const res = await fetch('/api/fonts');
|
|
const data = await res.json();
|
|
const el = document.getElementById('families');
|
|
el.innerHTML = '';
|
|
if (!data.families || data.families.length === 0) {
|
|
const p = document.createElement('p');
|
|
p.className = 'empty';
|
|
p.textContent = 'No fonts installed';
|
|
el.appendChild(p);
|
|
return;
|
|
}
|
|
|
|
data.families.forEach(f => {
|
|
const familyRow = document.createElement('div');
|
|
familyRow.className = 'family';
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'family-info';
|
|
|
|
const title = document.createElement('h3');
|
|
title.textContent = f.name;
|
|
|
|
const meta = document.createElement('span');
|
|
meta.className = 'family-meta';
|
|
const sizes = Array.isArray(f.sizes) ? f.sizes.join(', ') : '';
|
|
const fileSizes = Array.isArray(f.files) ? f.files.map(fi => formatSize(fi.size)).join(' + ') : '';
|
|
meta.textContent = sizes + 'pt - ' + fileSizes;
|
|
|
|
info.appendChild(title);
|
|
info.appendChild(meta);
|
|
|
|
const delBtn = document.createElement('button');
|
|
delBtn.className = 'btn btn-danger';
|
|
delBtn.dataset.family = f.name;
|
|
delBtn.textContent = 'Delete';
|
|
|
|
familyRow.appendChild(info);
|
|
familyRow.appendChild(delBtn);
|
|
el.appendChild(familyRow);
|
|
});
|
|
|
|
el.querySelectorAll('button[data-family]').forEach(btn => {
|
|
btn.addEventListener('click', () => deleteFamily(btn.dataset.family));
|
|
});
|
|
} catch (e) {
|
|
document.getElementById('families').innerHTML =
|
|
'<p class="empty">Failed to load font list</p>';
|
|
}
|
|
}
|
|
|
|
async function deleteFamily(name) {
|
|
if (!confirm('Delete font family "' + name + '"?')) return;
|
|
setStatus('status', 'Deleting ' + name + '...');
|
|
try {
|
|
const res = await fetch('/api/fonts/delete', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({family: name})
|
|
});
|
|
if (res.ok) {
|
|
setStatus('status', 'Deleted "' + name + '".', true);
|
|
} else {
|
|
setStatus('status', 'Failed to delete "' + name + '".', false);
|
|
}
|
|
} catch (err) {
|
|
setStatus('status', 'Delete error: ' + err.message, false);
|
|
}
|
|
await loadFonts();
|
|
await loadRemoteManifest();
|
|
}
|
|
|
|
async function loadRemoteManifest() {
|
|
const el = document.getElementById('remoteFamilies');
|
|
el.innerHTML = '<p class="empty">Loading catalog...</p>';
|
|
try {
|
|
const res = await fetch('/api/fonts/manifest');
|
|
const data = await res.json();
|
|
if (!res.ok || !data.ok) {
|
|
throw new Error(data.error || 'catalog unavailable');
|
|
}
|
|
|
|
el.innerHTML = '';
|
|
if (!Array.isArray(data.families) || data.families.length === 0) {
|
|
el.innerHTML = '<p class="empty">No remote fonts found</p>';
|
|
return;
|
|
}
|
|
|
|
data.families.forEach(f => {
|
|
const row = document.createElement('div');
|
|
row.className = 'family';
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'family-info';
|
|
|
|
const title = document.createElement('h3');
|
|
title.textContent = f.name;
|
|
|
|
const meta = document.createElement('span');
|
|
meta.className = 'family-meta';
|
|
const state = f.installed ? (f.hasUpdate ? 'Installed, update available' : 'Installed, up to date') : 'Not installed';
|
|
const details = state + ' - ' + (f.fileCount || 0) + ' file(s), ' + formatSize(f.totalSize || 0);
|
|
meta.textContent = f.description ? (f.description + ' - ' + details) : details;
|
|
|
|
info.appendChild(title);
|
|
info.appendChild(meta);
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn btn-primary';
|
|
btn.dataset.family = f.name;
|
|
btn.textContent = f.installed ? (f.hasUpdate ? 'Update' : 'Reinstall') : 'Download';
|
|
|
|
row.appendChild(info);
|
|
row.appendChild(btn);
|
|
el.appendChild(row);
|
|
});
|
|
|
|
el.querySelectorAll('button[data-family]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
await downloadRemoteFamily(btn.dataset.family);
|
|
btn.disabled = false;
|
|
});
|
|
});
|
|
} catch (err) {
|
|
el.innerHTML = '<p class="empty">Failed to load catalog: ' + err.message + '</p>';
|
|
}
|
|
}
|
|
|
|
async function downloadRemoteFamily(family) {
|
|
setStatus('remoteStatus', 'Downloading "' + family + '"...');
|
|
try {
|
|
const res = await fetch('/api/fonts/download', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({family})
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok || !data.ok) {
|
|
throw new Error(data.error || 'download failed');
|
|
}
|
|
setStatus('remoteStatus', 'Installed "' + family + '".', true);
|
|
await loadFonts();
|
|
await loadRemoteManifest();
|
|
} catch (err) {
|
|
setStatus('remoteStatus', 'Install failed for "' + family + '": ' + err.message, false);
|
|
}
|
|
}
|
|
|
|
async function downloadAllRemoteUpdates() {
|
|
setStatus('remoteStatus', 'Downloading all available updates...');
|
|
const btn = document.getElementById('downloadAllRemote');
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/fonts/download', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({all: true})
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok || !data.ok) {
|
|
throw new Error(data.error || 'download failed');
|
|
}
|
|
setStatus('remoteStatus', 'Installed/updated ' + (data.installedCount || 0) + ' family(s).', true);
|
|
await loadFonts();
|
|
await loadRemoteManifest();
|
|
} catch (err) {
|
|
setStatus('remoteStatus', 'Download-all failed: ' + err.message, false);
|
|
}
|
|
btn.disabled = false;
|
|
}
|
|
|
|
// Derive family name from a .cpfont filename: take everything before the
|
|
// last '-' or '_' (separator that precedes the size suffix, e.g. Bookerly_12.cpfont).
|
|
function familyFromFilename(name) {
|
|
const stem = name.replace(/\.cpfont$/i, '');
|
|
const cut = Math.max(stem.lastIndexOf('-'), stem.lastIndexOf('_'));
|
|
return cut > 0 ? stem.slice(0, cut) : stem;
|
|
}
|
|
|
|
// Sanitize to match firmware's [A-Za-z0-9_-]+ pattern.
|
|
function sanitizeFamily(raw) {
|
|
return raw.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
}
|
|
|
|
function cpfontFilesOnly(fileList) {
|
|
return Array.from(fileList).filter(f => /\.cpfont$/i.test(f.name));
|
|
}
|
|
|
|
document.getElementById('fontFiles').addEventListener('change', function() {
|
|
const info = document.getElementById('pickedInfo');
|
|
const files = cpfontFilesOnly(this.files);
|
|
if (files.length === 0) {
|
|
info.textContent = 'Pick one or more .cpfont files.';
|
|
return;
|
|
}
|
|
const families = new Set(files.map(f => sanitizeFamily(familyFromFilename(f.name))));
|
|
if (families.size > 1) {
|
|
info.textContent = 'Picked files contain multiple families — please select files from a single family.';
|
|
return;
|
|
}
|
|
const family = families.values().next().value;
|
|
info.textContent = files.length + ' file' + (files.length === 1 ? '' : 's') +
|
|
' → family "' + family + '"';
|
|
});
|
|
|
|
document.getElementById('uploadForm').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
const status = document.getElementById('status');
|
|
const files = cpfontFilesOnly(document.getElementById('fontFiles').files);
|
|
if (files.length === 0) {
|
|
setStatus('status', 'No .cpfont files selected.', false);
|
|
return;
|
|
}
|
|
|
|
const families = files.map(f => sanitizeFamily(familyFromFilename(f.name)));
|
|
const uniqueFamilies = new Set(families);
|
|
if (uniqueFamilies.size !== 1) {
|
|
setStatus('status', 'Selected files belong to multiple families.', false);
|
|
return;
|
|
}
|
|
|
|
const family = families[0];
|
|
|
|
setStatus('status', 'Preparing upload...');
|
|
|
|
let uploaded = 0;
|
|
for (const file of files) {
|
|
status.textContent = 'Uploading ' + (uploaded + 1) + '/' + files.length + ': ' + file.name;
|
|
const formData = new FormData();
|
|
formData.append('family', family);
|
|
formData.append('file', file, file.name);
|
|
try {
|
|
const res = await fetch('/api/fonts/upload', { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
if (!data.ok) {
|
|
setStatus('status', 'Failed on ' + file.name + ': ' + (data.error || 'unknown error'), false);
|
|
await loadFonts();
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
setStatus('status', 'Upload error on ' + file.name + ': ' + err.message, false);
|
|
await loadFonts();
|
|
return;
|
|
}
|
|
uploaded++;
|
|
}
|
|
|
|
setStatus('status', 'Uploaded ' + uploaded + ' file' + (uploaded === 1 ? '' : 's') +
|
|
' to family "' + family + '".', true);
|
|
await loadFonts();
|
|
await loadRemoteManifest();
|
|
});
|
|
|
|
document.getElementById('refreshManifest').addEventListener('click', loadRemoteManifest);
|
|
document.getElementById('downloadAllRemote').addEventListener('click', downloadAllRemoteUpdates);
|
|
|
|
loadFonts();
|
|
loadRemoteManifest();
|
|
</script>
|
|
</body>
|
|
</html>
|