Testing
This commit is contained in:
844
js/app.js
Normal file
844
js/app.js
Normal 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. Don’t 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>Don’t 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
Reference in New Issue
Block a user