This commit is contained in:
Zaine Qayyum
2026-08-19 09:56:06 +01:00
commit 9e075ec941
10 changed files with 7414 additions and 0 deletions

844
js/app.js Normal file
View File

@@ -0,0 +1,844 @@
const BOOK_STATUSES = ["reading", "shelf", "paused", "finished"];
const RESOURCE_STATUSES = ["backlog", "in_progress", "done"];
const UNFILED = "";
const THEME_KEY = "resourceLoader.theme";
const catalogEl = document.getElementById("catalog");
const statsEl = document.getElementById("stats");
const searchEl = document.getElementById("search");
const toastEl = document.getElementById("toast");
const dateEl = document.getElementById("datestamp");
const folderListEl = document.getElementById("folder-list");
const filePathEl = document.getElementById("file-path");
const vaultNameEl = document.getElementById("vault-name");
const vaultSelectEl = document.getElementById("vault-select");
const dialogEl = document.getElementById("dialog");
const dialogForm = document.getElementById("dialog-form");
const dialogTitle = document.getElementById("dialog-title");
const dialogFields = document.getElementById("dialog-fields");
let catalog = { folders: [], books: [], resources: [] };
let settings = { vaults: [], activeVaultId: "" };
let vault = null;
let vaultReady = false;
let activeFolder = "all";
let dialogMode = null;
dateEl.textContent = formatDate(new Date());
initTheme();
init();
async function init() {
if (!window.libraryAPI) {
catalogEl.innerHTML = `
<div class="error">
<p><strong>Desktop app required.</strong></p>
<p>Run <code>npm start</code> from this folder, or build an .exe with <code>npm run build</code>.</p>
</div>`;
return;
}
bind();
await applyLoadResult(await window.libraryAPI.load());
}
function preferredTheme() {
const saved = localStorage.getItem(THEME_KEY);
if (saved === "dark" || saved === "light") return saved;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
const button = document.getElementById("theme-toggle");
if (button) button.textContent = theme === "dark" ? "Light mode" : "Dark mode";
}
function initTheme() {
applyTheme(preferredTheme());
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
localStorage.setItem(THEME_KEY, next);
applyTheme(next);
}
function bind() {
searchEl.addEventListener("input", render);
document.querySelectorAll(".filters input").forEach((input) => {
input.addEventListener("change", render);
});
document.getElementById("add-book").addEventListener("click", () => openBookDialog());
document.getElementById("add-resource").addEventListener("click", () => openResourceDialog());
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
document.getElementById("import-pdf").addEventListener("click", () => importPdfs());
document.getElementById("new-folder").addEventListener("click", () => openFolderDialog());
document.getElementById("open-vault").addEventListener("click", openExistingVault);
document.getElementById("new-vault").addEventListener("click", openNewVaultDialog);
document.getElementById("remove-vault").addEventListener("click", removeActiveVault);
vaultSelectEl.addEventListener("change", async (event) => {
await applyLoadResult(await window.libraryAPI.switchVault(event.target.value));
});
document.getElementById("dialog-cancel").addEventListener("click", () => dialogEl.close());
dialogForm.addEventListener("submit", onDialogSubmit);
folderListEl.addEventListener("click", onFolderClick);
catalogEl.addEventListener("click", onCatalogClick);
catalogEl.addEventListener("change", onCatalogChange);
catalogEl.addEventListener("focusout", onCatalogFocusOut);
bindDrop();
}
function existingPdfNames() {
return catalog.books.map((book) => book.sourceName).filter(Boolean);
}
function currentFolderId() {
return activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
}
async function applyLoadResult(result) {
settings = result.settings || { vaults: [], activeVaultId: "" };
vault = result.vault || null;
vaultReady = Boolean(result.ok);
catalog = result.ok && result.catalog ? result.catalog : { folders: [], books: [], resources: [] };
activeFolder = "all";
renderVaultChrome();
setWorkspaceEnabled(vaultReady);
if (!result.ok) {
catalogEl.innerHTML = vaultEmptyHtml(result);
statsEl.innerHTML = "";
folderListEl.innerHTML = "";
return;
}
render();
}
function renderVaultChrome() {
vaultNameEl.textContent = vault ? vault.name : "No vault";
filePathEl.textContent = vault ? vault.path : "Choose a Google Drive folder as a vault.";
vaultSelectEl.innerHTML = settings.vaults.length
? settings.vaults
.map(
(item) =>
`<option value="${escapeAttr(item.id)}" ${item.id === settings.activeVaultId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
)
.join("")
: `<option value="">No vaults on this PC</option>`;
vaultSelectEl.disabled = settings.vaults.length === 0;
document.getElementById("remove-vault").disabled = !vault;
}
function setWorkspaceEnabled(enabled) {
["import-pdf", "add-book", "add-resource", "new-folder", "search"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.disabled = !enabled;
});
}
function vaultEmptyHtml(result) {
if (result.reason === "missing-folder") {
return `
<div class="error empty-vault">
<p><strong>Vault folder not found.</strong></p>
<p>${escapeHtml(result.path || "")}</p>
<p>If this lives on Google Drive, wait until Drive finishes syncing, then Open vault again. Dont edit the same vault on two PCs at once.</p>
</div>`;
}
if (result.reason === "unreadable") {
return `
<div class="error empty-vault">
<p><strong>Could not read library.json.</strong></p>
<p>${escapeHtml(result.error || "")}</p>
<p>Drive may still be syncing. Try again in a moment.</p>
</div>`;
}
return `
<div class="empty-vault">
<p><strong>No vault open on this computer.</strong></p>
<p>A vault is a folder (usually in Google Drive) with <code>library.json</code> and a <code>pdfs</code> folder. Use one vault for Work and another for Personal.</p>
<ol>
<li>Install Google Drive for desktop and wait for folders to sync.</li>
<li>New vault: create Work or Personal under <code>ResourceLoader</code> in Drive.</li>
<li>Open vault: point this app at that folder. Work laptop: add Work only.</li>
</ol>
<p>Dont edit the same vault on two PCs at once. Last save wins.</p>
</div>`;
}
async function openExistingVault() {
const result = await window.libraryAPI.openVault();
if (result.cancelled) return;
await applyLoadResult(result);
}
function openNewVaultDialog() {
dialogMode = "vault-create";
dialogTitle.textContent = "New vault";
dialogFields.innerHTML = `
<label class="field wide"><span>Name</span><input name="name" required placeholder="Work or Personal" /></label>
<p class="muted">Next you will choose the parent folder (for example your Google Drive ResourceLoader folder).</p>
`;
dialogEl.showModal();
}
async function removeActiveVault() {
if (!vault) return;
if (!confirm(`Remove “${vault.name}” from this PC? Files in Drive are not deleted.`)) return;
await applyLoadResult(await window.libraryAPI.removeVault(vault.id));
}
async function importPdfs() {
const result = await window.libraryAPI.importPdfs(existingPdfNames());
await applyImportResult(result);
}
async function applyImportResult(result) {
if (!result || result.cancelled) return;
if (result.ok === false) {
showToast(result.reason === "no-vault" ? "Open a vault first." : vaultMessage(result));
return;
}
const folderId = currentFolderId();
for (const pdf of result.imported || []) {
catalog.books.push({
id: makeId(pdf.title),
title: pdf.title,
author: pdf.author || "",
format: "pdf",
status: "shelf",
totalPages: Number(pdf.totalPages) || 0,
currentPage: 0,
folderId,
tags: Array.isArray(pdf.tags) ? pdf.tags : [],
notes: pdf.notes || "",
pdfPath: pdf.pdfPath || "",
sourceName: pdf.sourceName || "",
});
}
if (result.imported && result.imported.length) {
await persist();
render();
}
const parts = [];
if (result.imported?.length) parts.push(`Imported ${result.imported.length} PDF${result.imported.length === 1 ? "" : "s"}.`);
if (result.skipped?.length) parts.push(`Skipped ${result.skipped.length}.`);
if (result.errors?.length) parts.push(`Failed ${result.errors.length}.`);
if (parts.length) showToast(parts.join(" "));
}
function bindDrop() {
const overlay = document.getElementById("drop-overlay");
let dragDepth = 0;
window.addEventListener("dragenter", (event) => {
event.preventDefault();
dragDepth += 1;
overlay.hidden = false;
});
window.addEventListener("dragover", (event) => {
event.preventDefault();
overlay.hidden = false;
});
window.addEventListener("dragleave", (event) => {
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) overlay.hidden = true;
});
window.addEventListener("drop", async (event) => {
event.preventDefault();
dragDepth = 0;
overlay.hidden = true;
const files = [...(event.dataTransfer?.files || [])];
const paths = files
.map((file) => {
try {
return window.libraryAPI.pathForFile(file);
} catch {
return "";
}
})
.filter((filePath) => filePath && filePath.toLowerCase().endsWith(".pdf"));
if (!paths.length) {
showToast("Drop one or more PDF files.");
return;
}
const result = await window.libraryAPI.importPdfPaths(paths, existingPdfNames());
await applyImportResult(result);
});
}
function vaultMessage(result) {
if (result.reason === "missing-folder") return "Vault folder not found. Wait for Drive to sync.";
if (result.reason === "unreadable") return "Could not read library.json.";
if (result.reason === "no-vault") return "Open a vault first.";
return result.error || "Vault is not ready.";
}
async function persist() {
if (!vaultReady) return;
const result = await window.libraryAPI.save(catalog);
if (!result.ok) {
showToast(vaultMessage(result));
return;
}
catalog = result.catalog;
}
function inFolder(item) {
if (activeFolder === "all") return true;
if (activeFolder === "unfiled") return !item.folderId;
return item.folderId === activeFolder;
}
function itemCount(folderId) {
const match = (item) =>
folderId === "unfiled" ? !item.folderId : item.folderId === folderId;
return catalog.books.filter(match).length + catalog.resources.filter(match).length;
}
function folderOptions(selected) {
const rows = [`<option value="${UNFILED}" ${!selected ? "selected" : ""}>Unfiled</option>`];
for (const folder of sortedFolders()) {
const prefix = folder.parentId ? "— " : "";
rows.push(
`<option value="${escapeAttr(folder.id)}" ${folder.id === selected ? "selected" : ""}>${prefix}${escapeHtml(folder.name)}</option>`
);
}
return rows.join("");
}
function sortedFolders() {
const roots = catalog.folders.filter((f) => !f.parentId).sort(byName);
const children = catalog.folders.filter((f) => f.parentId);
const ordered = [];
for (const root of roots) {
ordered.push(root);
ordered.push(...children.filter((c) => c.parentId === root.id).sort(byName));
}
const hanging = children.filter((c) => !ordered.includes(c)).sort(byName);
return [...ordered, ...hanging];
}
function byName(a, b) {
return a.name.localeCompare(b.name);
}
function getFilters() {
const checked = (name) =>
[...document.querySelectorAll(`input[name="${name}"]:checked`)].map((el) => el.value);
return {
query: searchEl.value.trim().toLowerCase(),
formats: checked("format"),
kinds: checked("kind"),
statuses: checked("status"),
};
}
function matches(item, filters) {
if (!inFolder(item)) return false;
if (filters.statuses.length && !filters.statuses.includes(item.status)) return false;
if (item.type === "book") {
if (filters.formats.length && !filters.formats.includes(item.format)) return false;
if (filters.kinds.length && !filters.formats.length) return false;
} else {
if (filters.kinds.length && !filters.kinds.includes(item.kind)) return false;
if (filters.formats.length && !filters.kinds.length) return false;
}
if (!filters.query) return true;
const hay = [
item.title,
item.author,
item.source,
item.format,
item.kind,
...(item.tags || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return hay.includes(filters.query);
}
function tagged(list, type) {
return list.map((item) => ({ ...item, type }));
}
function render() {
renderFolders();
const books = tagged(catalog.books, "book");
const resources = tagged(catalog.resources, "resource");
const filters = getFilters();
const visibleBooks = books.filter((b) => matches(b, filters));
const visibleResources = resources.filter((r) => matches(r, filters));
renderStats(books.filter(inFolder), resources.filter(inFolder));
const nowReading = [
...visibleBooks.filter((b) => b.status === "reading"),
...visibleResources.filter((r) => r.status === "in_progress"),
];
const shelf = [
...visibleBooks.filter((b) => b.status === "shelf" || b.status === "paused"),
...visibleResources.filter((r) => r.status === "backlog"),
];
const finished = [
...visibleBooks.filter((b) => b.status === "finished"),
...visibleResources.filter((r) => r.status === "done"),
];
catalogEl.innerHTML = [
section("Now reading", nowReading),
section("On the shelf", shelf),
section("Finished", finished),
].join("");
}
function renderFolders() {
const allCount = catalog.books.length + catalog.resources.length;
const items = [
folderButton("all", "All items", allCount, false),
folderButton("unfiled", "Unfiled", itemCount("unfiled"), false),
...sortedFolders().map((folder) =>
folderButton(folder.id, folder.name, itemCount(folder.id), Boolean(folder.parentId), folder.id)
),
];
folderListEl.innerHTML = items.join("");
}
function folderButton(id, name, count, nested, folderId) {
const active = activeFolder === id ? " active" : "";
const nest = nested ? " nested" : "";
const manage =
folderId
? `<button type="button" data-folder-action="rename" data-id="${escapeAttr(folderId)}" title="Rename">✎</button>
<button type="button" data-folder-action="delete" data-id="${escapeAttr(folderId)}" title="Delete">×</button>`
: "";
return `
<div class="folder-row">
<button type="button" class="folder-item${active}${nest}" data-folder="${escapeAttr(id)}">
<span>${escapeHtml(name)}</span>
<span class="count">${count}</span>
</button>
${manage}
</div>`;
}
function renderStats(books, resources) {
const reading = books.filter((b) => b.status === "reading").length;
const shelf = books.filter((b) => b.status === "shelf" || b.status === "paused").length;
const pages = books.reduce((sum, b) => sum + (Number(b.currentPage) || 0), 0);
const backlog = resources.filter((r) => r.status === "backlog").length;
statsEl.innerHTML = [
stat(reading, "Now reading"),
stat(shelf, "On the shelf"),
stat(pages, "Pages logged"),
stat(backlog, "Resource backlog"),
].join("");
}
function stat(value, label) {
return `<div class="stat"><span class="value">${escapeHtml(String(value))}</span><span class="label">${escapeHtml(label)}</span></div>`;
}
function section(title, items) {
const body =
items.length === 0
? `<p class="empty">— none —</p>`
: `<div class="cards">${items.map(card).join("")}</div>`;
return `<section class="section"><h2>${escapeHtml(title)}</h2>${body}</section>`;
}
function card(item) {
return item.type === "book" ? bookCard(item) : resourceCard(item);
}
function bookCard(book) {
const total = Number(book.totalPages) || 0;
const page = Number(book.currentPage) || 0;
const pct = total ? Math.round((page / total) * 100) : 0;
return `
<article class="card" data-id="${escapeAttr(book.id)}" data-type="book">
<div class="card-head">
<div>
<h3>${escapeHtml(book.title)}</h3>
<p class="meta">${escapeHtml(book.author || "Unknown author")}</p>
${tagsHtml(book.tags)}
</div>
<div class="stamps">
<span class="stamp">${escapeHtml(book.format || "book")}</span>
<span class="stamp filled">${escapeHtml(labelStatus(book.status))}</span>
</div>
</div>
<div class="progress-row">
<button type="button" data-action="page" data-delta="-1" aria-label="Previous page"></button>
<input type="number" min="0" max="${total}" value="${page}" data-field="currentPage" aria-label="Current page" />
<span class="of">/ ${total}</span>
<button type="button" data-action="page" data-delta="1" aria-label="Next page">+</button>
</div>
<div class="bar" aria-hidden="true"><span style="width:${pct}%"></span></div>
<div class="card-controls">
${statusField(book.status, BOOK_STATUSES)}
${folderField(book.folderId)}
${notesField(book.notes)}
</div>
<div class="card-foot">
<span>${pct}% of ${total} pp.</span>
<span class="card-foot-actions">
${book.pdfPath ? `<button type="button" data-action="open-pdf">Open PDF</button>` : ""}
<button type="button" data-action="delete">Delete</button>
</span>
</div>
</article>`;
}
function resourceCard(item) {
const url = item.url || "";
return `
<article class="card" data-id="${escapeAttr(item.id)}" data-type="resource">
<div class="card-head">
<div>
<h3>${escapeHtml(item.title)}</h3>
<p class="meta">${escapeHtml(item.source || "Unknown source")}</p>
${tagsHtml(item.tags)}
</div>
<div class="stamps">
<span class="stamp">${escapeHtml(item.kind || "resource")}</span>
<span class="stamp filled">${escapeHtml(labelStatus(item.status))}</span>
</div>
</div>
<div class="card-controls">
${statusField(item.status, RESOURCE_STATUSES)}
${folderField(item.folderId)}
${notesField(item.notes)}
</div>
<div class="card-foot">
${
url
? `<a href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(item.kind || "link")}</a>`
: `<span class="muted">No URL</span>`
}
<button type="button" data-action="delete">Delete</button>
</div>
</article>`;
}
function statusField(status, options) {
const opts = options
.map(
(value) =>
`<option value="${escapeAttr(value)}" ${value === status ? "selected" : ""}>${escapeHtml(labelStatus(value))}</option>`
)
.join("");
return `<label class="field"><span>Status</span><select data-field="status">${opts}</select></label>`;
}
function folderField(folderId) {
return `<label class="field"><span>Folder</span><select data-field="folderId">${folderOptions(folderId || "")}</select></label>`;
}
function notesField(notes) {
return `<label class="field wide"><span>Notes</span><textarea data-field="notes" rows="2">${escapeHtml(notes || "")}</textarea></label>`;
}
function tagsHtml(tags) {
if (!tags || !tags.length) return "";
return `<p class="tags">${tags.map((t) => `#${escapeHtml(t)}`).join(" ")}</p>`;
}
function labelStatus(status) {
return String(status || "").replaceAll("_", " ");
}
function onFolderClick(event) {
const manage = event.target.closest("[data-folder-action]");
if (manage) {
const id = manage.dataset.id;
if (manage.dataset.folderAction === "rename") openFolderDialog(id);
if (manage.dataset.folderAction === "delete") deleteFolder(id);
return;
}
const button = event.target.closest("[data-folder]");
if (!button) return;
activeFolder = button.dataset.folder;
render();
}
async function deleteFolder(id) {
const folder = catalog.folders.find((f) => f.id === id);
if (!folder) return;
if (!confirm(`Delete folder “${folder.name}”? Items inside move to Unfiled.`)) return;
catalog.folders = catalog.folders.filter((f) => f.id !== id && f.parentId !== id);
for (const book of catalog.books) {
if (book.folderId === id) book.folderId = "";
}
for (const resource of catalog.resources) {
if (resource.folderId === id) resource.folderId = "";
}
if (activeFolder === id) activeFolder = "all";
await persist();
render();
showToast(`Deleted folder ${folder.name}.`);
}
async function onCatalogClick(event) {
const button = event.target.closest("button[data-action]");
if (!button) return;
const cardEl = button.closest(".card");
if (!cardEl) return;
const id = cardEl.dataset.id;
const kind = cardEl.dataset.type;
if (button.dataset.action === "delete") {
if (!confirm("Delete this item?")) return;
if (kind === "book") catalog.books = catalog.books.filter((b) => b.id !== id);
else catalog.resources = catalog.resources.filter((r) => r.id !== id);
await persist();
render();
showToast("Deleted.");
return;
}
if (button.dataset.action === "open-pdf") {
const book = catalog.books.find((b) => b.id === id);
if (!book?.pdfPath) return;
const opened = await window.libraryAPI.openPdf(book.pdfPath);
if (!opened.ok) showToast(opened.error || "Could not open PDF.");
return;
}
if (button.dataset.action === "page") {
const book = catalog.books.find((b) => b.id === id);
if (!book) return;
const next = clamp(
(Number(book.currentPage) || 0) + Number(button.dataset.delta),
0,
Number(book.totalPages) || 0
);
book.currentPage = next;
await persist();
render();
}
}
async function onCatalogChange(event) {
const field = event.target.dataset.field;
const cardEl = event.target.closest(".card");
if (!field || !cardEl || field === "notes") return;
const item = findItem(cardEl.dataset.id, cardEl.dataset.type);
if (!item) return;
if (field === "status") item.status = event.target.value;
if (field === "folderId") item.folderId = event.target.value;
if (field === "currentPage") {
const total = Number(item.totalPages) || 0;
item.currentPage = clamp(Number(event.target.value) || 0, 0, total);
}
await persist();
render();
}
async function onCatalogFocusOut(event) {
if (event.target.dataset.field !== "notes") return;
const cardEl = event.target.closest(".card");
if (!cardEl) return;
const item = findItem(cardEl.dataset.id, cardEl.dataset.type);
if (!item || item.notes === event.target.value) return;
item.notes = event.target.value;
await persist();
}
function findItem(id, type) {
return type === "book"
? catalog.books.find((b) => b.id === id)
: catalog.resources.find((r) => r.id === id);
}
function openBookDialog() {
dialogMode = "book";
dialogTitle.textContent = "Add book";
const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
dialogFields.innerHTML = `
<label class="field wide"><span>Title</span><input name="title" required /></label>
<label class="field"><span>Author</span><input name="author" /></label>
<label class="field"><span>Format</span>
<select name="format">
<option value="physical">Physical</option>
<option value="pdf">PDF</option>
</select>
</label>
<label class="field"><span>Status</span>
<select name="status">${options(BOOK_STATUSES, "shelf")}</select>
</label>
<label class="field"><span>Total pages</span><input name="totalPages" type="number" min="0" value="0" /></label>
<label class="field"><span>Current page</span><input name="currentPage" type="number" min="0" value="0" /></label>
<label class="field"><span>Folder</span><select name="folderId">${folderOptions(folderPrefill)}</select></label>
<label class="field"><span>Tags</span><input name="tags" placeholder="comma separated" /></label>
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
`;
dialogEl.showModal();
}
function openResourceDialog() {
dialogMode = "resource";
dialogTitle.textContent = "Add resource";
const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
dialogFields.innerHTML = `
<label class="field wide"><span>Title</span><input name="title" required /></label>
<label class="field"><span>Kind</span>
<select name="kind">
<option value="article">Article</option>
<option value="video">Video</option>
</select>
</label>
<label class="field"><span>Status</span>
<select name="status">${options(RESOURCE_STATUSES, "backlog")}</select>
</label>
<label class="field wide"><span>URL</span><input name="url" type="url" placeholder="https://" /></label>
<label class="field"><span>Source</span><input name="source" placeholder="site or channel" /></label>
<label class="field"><span>Folder</span><select name="folderId">${folderOptions(folderPrefill)}</select></label>
<label class="field wide"><span>Tags</span><input name="tags" placeholder="comma separated" /></label>
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
`;
dialogEl.showModal();
}
function openFolderDialog(id) {
const existing = catalog.folders.find((f) => f.id === id);
dialogMode = existing ? "folder-edit" : "folder";
dialogTitle.textContent = existing ? "Rename folder" : "New folder";
const parentPrefill = !existing && activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
dialogFields.innerHTML = `
<input type="hidden" name="id" value="${escapeAttr(existing ? existing.id : "")}" />
<label class="field wide"><span>Name</span><input name="name" required value="${escapeAttr(existing ? existing.name : "")}" /></label>
${
existing
? ""
: `<label class="field wide"><span>Inside</span>
<select name="parentId">
<option value="">Top level</option>
${catalog.folders
.filter((f) => !f.parentId)
.sort(byName)
.map(
(f) =>
`<option value="${escapeAttr(f.id)}" ${f.id === parentPrefill ? "selected" : ""}>${escapeHtml(f.name)}</option>`
)
.join("")}
</select>
</label>`
}
`;
dialogEl.showModal();
}
function options(values, selected) {
return values
.map(
(value) =>
`<option value="${escapeAttr(value)}" ${value === selected ? "selected" : ""}>${escapeHtml(labelStatus(value))}</option>`
)
.join("");
}
async function onDialogSubmit(event) {
event.preventDefault();
const data = Object.fromEntries(new FormData(dialogForm));
if (dialogMode === "book") {
catalog.books.push({
id: makeId(data.title),
title: data.title.trim(),
author: (data.author || "").trim(),
format: data.format === "pdf" ? "pdf" : "physical",
status: BOOK_STATUSES.includes(data.status) ? data.status : "shelf",
totalPages: Math.max(0, Number(data.totalPages) || 0),
currentPage: clamp(Number(data.currentPage) || 0, 0, Math.max(0, Number(data.totalPages) || 0)),
folderId: data.folderId || "",
tags: parseTags(data.tags),
notes: data.notes || "",
});
showToast("Book added.");
} else if (dialogMode === "resource") {
catalog.resources.push({
id: makeId(data.title),
title: data.title.trim(),
kind: data.kind === "video" ? "video" : "article",
url: (data.url || "").trim(),
source: (data.source || "").trim(),
status: RESOURCE_STATUSES.includes(data.status) ? data.status : "backlog",
folderId: data.folderId || "",
tags: parseTags(data.tags),
notes: data.notes || "",
});
showToast("Resource added.");
} else if (dialogMode === "folder") {
catalog.folders.push({
id: makeId(data.name),
name: data.name.trim(),
parentId: data.parentId || "",
});
showToast("Folder created.");
} else if (dialogMode === "folder-edit") {
const folder = catalog.folders.find((f) => f.id === data.id);
if (folder) folder.name = data.name.trim();
showToast("Folder renamed.");
} else if (dialogMode === "vault-create") {
dialogEl.close();
const result = await window.libraryAPI.createVault(data.name.trim());
if (result.cancelled) return;
await applyLoadResult(result);
showToast(result.ok ? "Vault created." : vaultMessage(result));
return;
}
dialogEl.close();
await persist();
render();
}
function parseTags(value) {
return String(value || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
function makeId(title) {
const slug = String(title || "item")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40);
return `${slug || "item"}-${Date.now().toString(36)}`;
}
function showToast(message) {
toastEl.hidden = false;
toastEl.textContent = message;
}
function formatDate(date) {
return date
.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
})
.toUpperCase();
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value);
}