Add Resource Loader SPA and JS test harness
All checks were successful
Build Org Website / build (push) Successful in 38s
All checks were successful
Build Org Website / build (push) Successful in 38s
This commit is contained in:
229
assets/scripts/pages/resource-loader-model.js
Normal file
229
assets/scripts/pages/resource-loader-model.js
Normal file
@@ -0,0 +1,229 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
root.ResourceLoaderModel = api;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const TYPES = ["book", "article", "video"];
|
||||
const FORMATS = ["physical", "pdf", "web"];
|
||||
const STATUSES = ["backlog", "shelf", "active", "completed", "archived"];
|
||||
|
||||
function id() {
|
||||
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `rl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function emptyVault(name, colour) {
|
||||
const stamp = now();
|
||||
return {
|
||||
id: id(),
|
||||
name,
|
||||
colour: colour || "#9b6b43",
|
||||
folders: [],
|
||||
resources: [],
|
||||
createdAt: stamp,
|
||||
updatedAt: stamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createSeed() {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
vaults: [emptyVault("Work", "#526d82"), emptyVault("Personal Study", "#9b6b43")],
|
||||
};
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function validateLibrary(input) {
|
||||
assert(input && typeof input === "object" && !Array.isArray(input), "The JSON root must be an object.");
|
||||
assert(input.schemaVersion === SCHEMA_VERSION, `Unsupported schemaVersion; expected ${SCHEMA_VERSION}.`);
|
||||
assert(Array.isArray(input.vaults), "vaults must be an array.");
|
||||
|
||||
const vaultIds = new Set();
|
||||
input.vaults.forEach((vault, vaultIndex) => {
|
||||
assert(vault && typeof vault === "object", `Vault ${vaultIndex + 1} is invalid.`);
|
||||
assert(typeof vault.id === "string" && vault.id, `Vault ${vaultIndex + 1} needs an id.`);
|
||||
assert(!vaultIds.has(vault.id), `Duplicate vault id: ${vault.id}`);
|
||||
vaultIds.add(vault.id);
|
||||
assert(typeof vault.name === "string" && vault.name.trim(), `Vault ${vaultIndex + 1} needs a name.`);
|
||||
assert(Array.isArray(vault.folders), `folders must be an array in ${vault.name}.`);
|
||||
assert(Array.isArray(vault.resources), `resources must be an array in ${vault.name}.`);
|
||||
|
||||
const folderIds = new Set();
|
||||
vault.folders.forEach((folder) => {
|
||||
assert(folder && typeof folder.id === "string" && folder.id, `A folder in ${vault.name} needs an id.`);
|
||||
assert(!folderIds.has(folder.id), `Duplicate folder id: ${folder.id}`);
|
||||
folderIds.add(folder.id);
|
||||
assert(typeof folder.name === "string" && folder.name.trim(), `A folder in ${vault.name} needs a name.`);
|
||||
});
|
||||
|
||||
const resourceIds = new Set();
|
||||
vault.resources.forEach((resource) => {
|
||||
assert(resource && typeof resource.id === "string" && resource.id, `A resource in ${vault.name} needs an id.`);
|
||||
assert(!resourceIds.has(resource.id), `Duplicate resource id: ${resource.id}`);
|
||||
resourceIds.add(resource.id);
|
||||
assert(typeof resource.title === "string" && resource.title.trim(), "Every resource needs a title.");
|
||||
assert(TYPES.includes(resource.type), `Invalid resource type: ${resource.type}`);
|
||||
assert(FORMATS.includes(resource.format), `Invalid resource format: ${resource.format}`);
|
||||
assert(STATUSES.includes(resource.status), `Invalid resource status: ${resource.status}`);
|
||||
assert(!resource.folderId || folderIds.has(resource.folderId), `Resource ${resource.title} refers to a missing folder.`);
|
||||
assert(!resource.sessions || Array.isArray(resource.sessions), `sessions must be an array for ${resource.title}.`);
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function normaliseLibrary(input) {
|
||||
validateLibrary(input);
|
||||
const data = clone(input);
|
||||
data.vaults.forEach((vault) => {
|
||||
vault.colour = vault.colour || "#9b6b43";
|
||||
vault.createdAt = vault.createdAt || now();
|
||||
vault.updatedAt = vault.updatedAt || vault.createdAt;
|
||||
vault.folders.forEach((folder) => {
|
||||
folder.createdAt = folder.createdAt || now();
|
||||
folder.updatedAt = folder.updatedAt || folder.createdAt;
|
||||
});
|
||||
vault.resources.forEach((resource) => {
|
||||
resource.creators = Array.isArray(resource.creators) ? resource.creators : [];
|
||||
resource.tags = Array.isArray(resource.tags) ? resource.tags : [];
|
||||
resource.sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
|
||||
resource.folderId = resource.folderId || null;
|
||||
resource.createdAt = resource.createdAt || now();
|
||||
resource.updatedAt = resource.updatedAt || resource.createdAt;
|
||||
});
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function mergeById(existing, incoming) {
|
||||
const map = new Map(existing.map((item) => [item.id, clone(item)]));
|
||||
incoming.forEach((item) => map.set(item.id, clone(item)));
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function mergeLibraries(current, incoming) {
|
||||
const base = normaliseLibrary(current);
|
||||
const addition = normaliseLibrary(incoming);
|
||||
const vaultMap = new Map(base.vaults.map((vault) => [vault.id, vault]));
|
||||
|
||||
addition.vaults.forEach((newVault) => {
|
||||
const oldVault = vaultMap.get(newVault.id);
|
||||
if (!oldVault) {
|
||||
vaultMap.set(newVault.id, newVault);
|
||||
return;
|
||||
}
|
||||
vaultMap.set(newVault.id, {
|
||||
...oldVault,
|
||||
...newVault,
|
||||
folders: mergeById(oldVault.folders, newVault.folders),
|
||||
resources: mergeById(oldVault.resources, newVault.resources),
|
||||
});
|
||||
});
|
||||
|
||||
const merged = { schemaVersion: SCHEMA_VERSION, vaults: Array.from(vaultMap.values()) };
|
||||
return normaliseLibrary(merged);
|
||||
}
|
||||
|
||||
function progressFor(resource) {
|
||||
const sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
|
||||
const furthest = sessions.reduce((maximum, session) => {
|
||||
const value = Number(session.end);
|
||||
return Number.isFinite(value) ? Math.max(maximum, value) : maximum;
|
||||
}, 0);
|
||||
let total = 100;
|
||||
if (resource.type === "book") total = Number(resource.pageCount) || 0;
|
||||
if (resource.type === "video") total = Number(resource.durationMinutes) || 0;
|
||||
if (!total) return { current: furthest, total: 0, percent: resource.status === "completed" ? 100 : 0 };
|
||||
const percent = Math.max(0, Math.min(100, Math.round((furthest / total) * 100)));
|
||||
return { current: furthest, total, percent: resource.status === "completed" ? 100 : percent };
|
||||
}
|
||||
|
||||
function addVault(library, name, colour) {
|
||||
const data = normaliseLibrary(library);
|
||||
data.vaults.push(emptyVault(name.trim(), colour));
|
||||
return data;
|
||||
}
|
||||
|
||||
function renameVault(library, vaultId, name) {
|
||||
const data = normaliseLibrary(library);
|
||||
const vault = data.vaults.find((item) => item.id === vaultId);
|
||||
assert(vault, "Vault not found.");
|
||||
vault.name = name.trim();
|
||||
vault.updatedAt = now();
|
||||
return data;
|
||||
}
|
||||
|
||||
function deleteVault(library, vaultId) {
|
||||
const data = normaliseLibrary(library);
|
||||
assert(data.vaults.length > 1, "Keep at least one vault.");
|
||||
data.vaults = data.vaults.filter((vault) => vault.id !== vaultId);
|
||||
return data;
|
||||
}
|
||||
|
||||
function addFolder(library, vaultId, name) {
|
||||
const data = normaliseLibrary(library);
|
||||
const vault = data.vaults.find((item) => item.id === vaultId);
|
||||
assert(vault, "Vault not found.");
|
||||
const stamp = now();
|
||||
vault.folders.push({ id: id(), name: name.trim(), createdAt: stamp, updatedAt: stamp });
|
||||
vault.updatedAt = stamp;
|
||||
return data;
|
||||
}
|
||||
|
||||
function deleteFolder(library, vaultId, folderId) {
|
||||
const data = normaliseLibrary(library);
|
||||
const vault = data.vaults.find((item) => item.id === vaultId);
|
||||
assert(vault, "Vault not found.");
|
||||
vault.folders = vault.folders.filter((folder) => folder.id !== folderId);
|
||||
vault.resources.forEach((resource) => {
|
||||
if (resource.folderId === folderId) resource.folderId = null;
|
||||
});
|
||||
vault.updatedAt = now();
|
||||
return data;
|
||||
}
|
||||
|
||||
function serialise(library) {
|
||||
const data = normaliseLibrary(library);
|
||||
data.exportedAt = now();
|
||||
data.vaults.forEach((vault) => vault.resources.forEach((resource) => {
|
||||
if (resource.attachmentId) resource.attachmentState = "reattach-required-after-import";
|
||||
}));
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
return {
|
||||
SCHEMA_VERSION,
|
||||
TYPES,
|
||||
FORMATS,
|
||||
STATUSES,
|
||||
id,
|
||||
now,
|
||||
createSeed,
|
||||
validateLibrary,
|
||||
normaliseLibrary,
|
||||
mergeLibraries,
|
||||
progressFor,
|
||||
addVault,
|
||||
renameVault,
|
||||
deleteVault,
|
||||
addFolder,
|
||||
deleteFolder,
|
||||
serialise,
|
||||
};
|
||||
});
|
||||
731
assets/scripts/pages/resource-loader.js
Normal file
731
assets/scripts/pages/resource-loader.js
Normal file
@@ -0,0 +1,731 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const root = document.getElementById("resource-loader");
|
||||
const Model = globalThis.ResourceLoaderModel;
|
||||
if (!root || !Model) return;
|
||||
|
||||
const DB_NAME = "zxh-resource-loader";
|
||||
const DB_VERSION = 1;
|
||||
const META_STORE = "metadata";
|
||||
const ATTACHMENT_STORE = "attachments";
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||||
const state = {
|
||||
db: null,
|
||||
library: null,
|
||||
vaultId: null,
|
||||
folderId: "all",
|
||||
view: localStorage.getItem("rl-view") || "list",
|
||||
importData: null,
|
||||
nameAction: null,
|
||||
pendingImport: null,
|
||||
pdfUrls: new Map(),
|
||||
thumbnailUrls: new Map(),
|
||||
};
|
||||
|
||||
const els = {
|
||||
vaultSelect: $("#rl-vault-select"),
|
||||
folderList: $("#rl-folder-list"),
|
||||
resourceList: $("#rl-resource-list"),
|
||||
contextLabel: $("#rl-context-label"),
|
||||
search: $("#rl-search"),
|
||||
typeFilter: $("#rl-filter-type"),
|
||||
statusFilter: $("#rl-filter-status"),
|
||||
tagFilter: $("#rl-filter-tag"),
|
||||
sort: $("#rl-sort"),
|
||||
resourceDialog: $("#rl-resource-dialog"),
|
||||
resourceForm: $("#rl-resource-form"),
|
||||
sessionDialog: $("#rl-session-dialog"),
|
||||
nameDialog: $("#rl-name-dialog"),
|
||||
vaultDialog: $("#rl-vault-dialog"),
|
||||
importDialog: $("#rl-import-dialog"),
|
||||
storageDialog: $("#rl-storage-dialog"),
|
||||
pdfInput: $("#rl-pdf-input"),
|
||||
jsonInput: $("#rl-json-input"),
|
||||
loading: $("#rl-loading"),
|
||||
toast: $("#rl-toast"),
|
||||
};
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(META_STORE)) db.createObjectStore(META_STORE);
|
||||
if (!db.objectStoreNames.contains(ATTACHMENT_STORE)) db.createObjectStore(ATTACHMENT_STORE, { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function request(storeName, mode, operation) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = state.db.transaction(storeName, mode);
|
||||
const store = transaction.objectStore(storeName);
|
||||
const result = operation(store);
|
||||
transaction.oncomplete = () => resolve(result && result.result);
|
||||
transaction.onerror = () => reject(transaction.error || (result && result.error));
|
||||
transaction.onabort = () => reject(transaction.error || new Error("Storage operation was aborted."));
|
||||
});
|
||||
}
|
||||
|
||||
function getMetadata(key) { return request(META_STORE, "readonly", (store) => store.get(key)); }
|
||||
function setMetadata(key, value) { return request(META_STORE, "readwrite", (store) => store.put(value, key)); }
|
||||
function getAttachment(id) { return id ? request(ATTACHMENT_STORE, "readonly", (store) => store.get(id)) : Promise.resolve(null); }
|
||||
function putAttachment(record) { return request(ATTACHMENT_STORE, "readwrite", (store) => store.put(record)); }
|
||||
function deleteAttachment(id) { return id ? request(ATTACHMENT_STORE, "readwrite", (store) => store.delete(id)) : Promise.resolve(); }
|
||||
|
||||
async function saveLibrary() {
|
||||
await setMetadata("library", state.library);
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
const saved = await getMetadata("library");
|
||||
if (saved) return Model.normaliseLibrary(saved);
|
||||
try {
|
||||
const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Seed library could not be loaded.");
|
||||
return Model.normaliseLibrary(await response.json());
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
return Model.createSeed();
|
||||
}
|
||||
}
|
||||
|
||||
function currentVault() {
|
||||
return state.library.vaults.find((vault) => vault.id === state.vaultId) || state.library.vaults[0];
|
||||
}
|
||||
|
||||
function currentResource(id) {
|
||||
return currentVault().resources.find((resource) => resource.id === id);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? "" : value)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function titleCase(value) {
|
||||
return String(value || "").replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function showLoading(message) {
|
||||
els.loading.querySelector("p").textContent = message || "Working…";
|
||||
els.loading.classList.add("is-visible");
|
||||
}
|
||||
|
||||
function hideLoading() { els.loading.classList.remove("is-visible"); }
|
||||
|
||||
let toastTimer;
|
||||
function toast(message, error) {
|
||||
clearTimeout(toastTimer);
|
||||
els.toast.textContent = message;
|
||||
els.toast.classList.toggle("is-error", Boolean(error));
|
||||
els.toast.classList.add("is-visible");
|
||||
toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 3600);
|
||||
}
|
||||
|
||||
function closeDialog(dialog) {
|
||||
if (dialog && dialog.open) dialog.close();
|
||||
}
|
||||
|
||||
function setValue(id, value) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) element.value = value == null ? "" : value;
|
||||
}
|
||||
|
||||
function renderVaultSelect() {
|
||||
const previous = state.vaultId;
|
||||
els.vaultSelect.innerHTML = state.library.vaults.map((vault) =>
|
||||
`<option value="${escapeHtml(vault.id)}">${escapeHtml(vault.name)}</option>`).join("");
|
||||
state.vaultId = state.library.vaults.some((vault) => vault.id === previous) ? previous : state.library.vaults[0].id;
|
||||
els.vaultSelect.value = state.vaultId;
|
||||
}
|
||||
|
||||
function resourceCount(folderId) {
|
||||
const resources = currentVault().resources;
|
||||
if (folderId === "all") return resources.length;
|
||||
if (folderId === "unfiled") return resources.filter((item) => !item.folderId).length;
|
||||
return resources.filter((item) => item.folderId === folderId).length;
|
||||
}
|
||||
|
||||
function folderRow(id, label, editable) {
|
||||
return `<div class="rl-folder-row${state.folderId === id ? " is-active" : ""}" data-folder-row="${escapeHtml(id)}">
|
||||
<button type="button" data-folder="${escapeHtml(id)}"><span>${escapeHtml(label)}</span><span>${resourceCount(id)}</span></button>
|
||||
${editable ? `<button type="button" class="rl-folder-menu" data-folder-menu="${escapeHtml(id)}" aria-label="Manage ${escapeHtml(label)}">•••</button>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFolders() {
|
||||
const vault = currentVault();
|
||||
if (!vault.folders.some((folder) => folder.id === state.folderId) && !["all", "unfiled"].includes(state.folderId)) state.folderId = "all";
|
||||
els.folderList.innerHTML = folderRow("all", "All resources", false) + folderRow("unfiled", "Unfiled", false) +
|
||||
'<div class="rl-folder-rule" aria-hidden="true"></div>' +
|
||||
vault.folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => folderRow(folder.id, folder.name, true)).join("");
|
||||
|
||||
els.folderList.querySelectorAll("[data-folder]").forEach((button) => button.addEventListener("click", () => {
|
||||
state.folderId = button.dataset.folder;
|
||||
render();
|
||||
}));
|
||||
els.folderList.querySelectorAll("[data-folder-menu]").forEach((button) => button.addEventListener("click", () => manageFolder(button.dataset.folderMenu)));
|
||||
}
|
||||
|
||||
function populateTagFilter() {
|
||||
const previous = els.tagFilter.value;
|
||||
const tags = Array.from(new Set(currentVault().resources.flatMap((resource) => resource.tags || []))).sort((a, b) => a.localeCompare(b));
|
||||
els.tagFilter.innerHTML = '<option value="">All tags</option>' + tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
|
||||
els.tagFilter.value = tags.includes(previous) ? previous : "";
|
||||
}
|
||||
|
||||
function filteredResources() {
|
||||
const query = els.search.value.trim().toLocaleLowerCase();
|
||||
let resources = currentVault().resources.filter((resource) => {
|
||||
if (state.folderId === "unfiled" && resource.folderId) return false;
|
||||
if (!["all", "unfiled"].includes(state.folderId) && resource.folderId !== state.folderId) return false;
|
||||
if (els.typeFilter.value && resource.type !== els.typeFilter.value) return false;
|
||||
if (els.statusFilter.value && resource.status !== els.statusFilter.value) return false;
|
||||
if (els.tagFilter.value && !(resource.tags || []).includes(els.tagFilter.value)) return false;
|
||||
const haystack = [resource.title, ...(resource.creators || []), ...(resource.tags || []), resource.description, resource.notes].join(" ").toLocaleLowerCase();
|
||||
return !query || haystack.includes(query);
|
||||
});
|
||||
resources.sort((a, b) => {
|
||||
if (els.sort.value === "title") return a.title.localeCompare(b.title);
|
||||
if (els.sort.value === "progress") return Model.progressFor(b).percent - Model.progressFor(a).percent;
|
||||
return String(b.updatedAt).localeCompare(String(a.updatedAt));
|
||||
});
|
||||
return resources;
|
||||
}
|
||||
|
||||
async function thumbnailUrl(id) {
|
||||
if (!id) return null;
|
||||
if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id);
|
||||
const attachment = await getAttachment(id);
|
||||
if (!attachment || !attachment.blob) return null;
|
||||
const url = URL.createObjectURL(attachment.blob);
|
||||
state.thumbnailUrls.set(id, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
function cardMarkup(resource) {
|
||||
const progress = Model.progressFor(resource);
|
||||
const initial = (resource.title || "?").trim().charAt(0).toUpperCase();
|
||||
const creators = (resource.creators || []).join(", ") || "Unknown creator";
|
||||
return `<article class="rl-resource-card" tabindex="0" role="button" data-resource-id="${escapeHtml(resource.id)}" aria-label="Open ${escapeHtml(resource.title)}">
|
||||
<span class="rl-resource-cover" data-thumb="${escapeHtml(resource.thumbnailId || "")}">${escapeHtml(initial)}</span>
|
||||
<span class="rl-resource-title"><strong>${escapeHtml(resource.title)}</strong><small>${escapeHtml(creators)}</small><small>${escapeHtml((resource.tags || []).slice(0, 3).join(" · "))}</small></span>
|
||||
<span class="rl-badges"><span class="rl-badge">${escapeHtml(resource.type)}</span><span class="rl-badge">${escapeHtml(resource.status)}</span><span class="rl-badge">${escapeHtml(resource.format)}</span></span>
|
||||
<span class="rl-progress"><span class="rl-progress__track"><span class="rl-progress__fill" style="width:${progress.percent}%"></span></span><small>${progress.percent}% complete</small></span>
|
||||
<span class="rl-card-arrow" aria-hidden="true">›</span>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
async function hydrateThumbnails(container) {
|
||||
const targets = Array.from(container.querySelectorAll("[data-thumb]")).filter((item) => item.dataset.thumb);
|
||||
await Promise.all(targets.map(async (target) => {
|
||||
const url = await thumbnailUrl(target.dataset.thumb);
|
||||
if (url && target.isConnected) target.innerHTML = `<img src="${url}" alt="" />`;
|
||||
}));
|
||||
}
|
||||
|
||||
function bindResourceCards() {
|
||||
els.resourceList.querySelectorAll("[data-resource-id]").forEach((card) => {
|
||||
const open = () => openResourceDialog(currentResource(card.dataset.resourceId));
|
||||
card.addEventListener("click", open);
|
||||
card.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderResources() {
|
||||
const resources = filteredResources();
|
||||
const folder = currentVault().folders.find((item) => item.id === state.folderId);
|
||||
els.contextLabel.textContent = state.folderId === "all" ? "All resources" : state.folderId === "unfiled" ? "Unfiled" : folder ? folder.name : "All resources";
|
||||
els.resourceList.classList.toggle("is-grid", state.view === "grid");
|
||||
$("#rl-view-list").classList.toggle("is-active", state.view === "list");
|
||||
$("#rl-view-grid").classList.toggle("is-active", state.view === "grid");
|
||||
$("#rl-view-list").setAttribute("aria-pressed", String(state.view === "list"));
|
||||
$("#rl-view-grid").setAttribute("aria-pressed", String(state.view === "grid"));
|
||||
|
||||
if (!resources.length) {
|
||||
const filtered = currentVault().resources.length > 0;
|
||||
els.resourceList.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">⌑</span><h3>${filtered ? "Nothing matches this view" : "Your shelf is ready"}</h3><p>${filtered ? "Try changing the folder, filters, or search." : "Add a physical book, save a link, or import a PDF to begin."}</p><button type="button" class="rl-primary" data-empty-add>Add resource</button></div>`;
|
||||
els.resourceList.querySelector("[data-empty-add]").addEventListener("click", () => openResourceDialog());
|
||||
return;
|
||||
}
|
||||
els.resourceList.innerHTML = resources.map(cardMarkup).join("");
|
||||
bindResourceCards();
|
||||
hydrateThumbnails(els.resourceList);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const resources = currentVault().resources;
|
||||
$("#rl-stat-total").textContent = resources.length;
|
||||
$("#rl-stat-active").textContent = resources.filter((item) => item.status === "active").length;
|
||||
$("#rl-stat-backlog").textContent = resources.filter((item) => ["backlog", "shelf"].includes(item.status)).length;
|
||||
$("#rl-stat-completed").textContent = resources.filter((item) => item.status === "completed").length;
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderVaultSelect();
|
||||
renderFolders();
|
||||
populateTagFilter();
|
||||
renderStats();
|
||||
renderResources();
|
||||
}
|
||||
|
||||
function populateFolderField(selected) {
|
||||
const field = $("#rl-field-folder");
|
||||
field.innerHTML = '<option value="">Unfiled</option>' + currentVault().folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => `<option value="${escapeHtml(folder.id)}">${escapeHtml(folder.name)}</option>`).join("");
|
||||
field.value = selected || "";
|
||||
}
|
||||
|
||||
function resetCover() {
|
||||
$("#rl-cover-preview").innerHTML = "<span>Cover</span>";
|
||||
}
|
||||
|
||||
async function showCover(id) {
|
||||
resetCover();
|
||||
const url = await thumbnailUrl(id);
|
||||
if (url) $("#rl-cover-preview").innerHTML = `<img src="${url}" alt="Imported PDF cover" />`;
|
||||
}
|
||||
|
||||
function renderSessions(resource) {
|
||||
const section = $("#rl-session-section");
|
||||
if (!resource) { section.hidden = true; return; }
|
||||
section.hidden = false;
|
||||
const sessions = (resource.sessions || []).slice().sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
||||
$("#rl-session-list").innerHTML = sessions.length ? sessions.map((session) => `<div class="rl-session-row">
|
||||
<time datetime="${escapeHtml(session.date)}">${escapeHtml(session.date)}</time>
|
||||
<strong>${escapeHtml(session.start)}–${escapeHtml(session.end)} ${escapeHtml(session.unit)}</strong>
|
||||
<span>${escapeHtml(session.note || "")}</span>
|
||||
<button type="button" class="rl-danger" data-delete-session="${escapeHtml(session.id)}">Delete</button>
|
||||
</div>`).join("") : '<p class="rl-help">No sessions logged yet.</p>';
|
||||
$("#rl-session-list").querySelectorAll("[data-delete-session]").forEach((button) => button.addEventListener("click", async () => {
|
||||
if (!confirm("Delete this session?")) return;
|
||||
resource.sessions = resource.sessions.filter((item) => item.id !== button.dataset.deleteSession);
|
||||
resource.updatedAt = Model.now();
|
||||
await saveLibrary();
|
||||
renderSessions(resource);
|
||||
render();
|
||||
toast("Session deleted.");
|
||||
}));
|
||||
}
|
||||
|
||||
async function openResourceDialog(resource, draft) {
|
||||
els.resourceForm.reset();
|
||||
resetCover();
|
||||
const item = resource || draft || null;
|
||||
state.pendingImport = draft ? { attachmentId: draft.attachmentId, thumbnailId: draft.thumbnailId } : null;
|
||||
$("#rl-resource-dialog-title").textContent = resource ? "Edit resource" : draft ? "Review imported PDF" : "Add resource";
|
||||
setValue("rl-resource-id", resource ? resource.id : "");
|
||||
setValue("rl-attachment-id", item && item.attachmentId);
|
||||
setValue("rl-thumbnail-id", item && item.thumbnailId);
|
||||
setValue("rl-field-title", item && item.title);
|
||||
setValue("rl-field-creators", item && (item.creators || []).join(", "));
|
||||
setValue("rl-field-type", item ? item.type : "book");
|
||||
setValue("rl-field-format", item ? item.format : "physical");
|
||||
setValue("rl-field-status", item ? item.status : "backlog");
|
||||
setValue("rl-field-pages", item && item.pageCount);
|
||||
setValue("rl-field-duration", item && item.durationMinutes);
|
||||
setValue("rl-field-url", item && item.url);
|
||||
setValue("rl-field-publisher", item && item.publisher);
|
||||
setValue("rl-field-published", item && item.published);
|
||||
setValue("rl-field-tags", item && (item.tags || []).join(", "));
|
||||
setValue("rl-field-description", item && item.description);
|
||||
setValue("rl-field-notes", item && item.notes);
|
||||
populateFolderField(item && item.folderId ? item.folderId : (!["all", "unfiled"].includes(state.folderId) ? state.folderId : ""));
|
||||
$("#rl-delete-resource").hidden = !resource;
|
||||
$("#rl-open-resource").hidden = !(item && (item.url || item.attachmentId));
|
||||
renderSessions(resource);
|
||||
if (item && item.thumbnailId) showCover(item.thumbnailId);
|
||||
els.resourceDialog.showModal();
|
||||
$("#rl-field-title").focus();
|
||||
}
|
||||
|
||||
function formResource(existing) {
|
||||
const stamp = Model.now();
|
||||
return {
|
||||
id: existing ? existing.id : Model.id(),
|
||||
title: $("#rl-field-title").value.trim(),
|
||||
creators: splitList($("#rl-field-creators").value),
|
||||
type: $("#rl-field-type").value,
|
||||
format: $("#rl-field-format").value,
|
||||
status: $("#rl-field-status").value,
|
||||
folderId: $("#rl-field-folder").value || null,
|
||||
pageCount: Number($("#rl-field-pages").value) || null,
|
||||
durationMinutes: Number($("#rl-field-duration").value) || null,
|
||||
url: $("#rl-field-url").value.trim(),
|
||||
publisher: $("#rl-field-publisher").value.trim(),
|
||||
published: $("#rl-field-published").value.trim(),
|
||||
tags: splitList($("#rl-field-tags").value),
|
||||
description: $("#rl-field-description").value.trim(),
|
||||
notes: $("#rl-field-notes").value.trim(),
|
||||
attachmentId: $("#rl-attachment-id").value || null,
|
||||
thumbnailId: $("#rl-thumbnail-id").value || null,
|
||||
sessions: existing ? existing.sessions || [] : [],
|
||||
createdAt: existing ? existing.createdAt : stamp,
|
||||
updatedAt: stamp,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveResource(event) {
|
||||
event.preventDefault();
|
||||
const vault = currentVault();
|
||||
const existing = currentResource($("#rl-resource-id").value);
|
||||
const resource = formResource(existing);
|
||||
if (!resource.title) return;
|
||||
if (existing) vault.resources[vault.resources.findIndex((item) => item.id === existing.id)] = resource;
|
||||
else vault.resources.push(resource);
|
||||
vault.updatedAt = Model.now();
|
||||
await saveLibrary();
|
||||
state.pendingImport = null;
|
||||
closeDialog(els.resourceDialog);
|
||||
render();
|
||||
toast(existing ? "Resource updated." : "Resource added.");
|
||||
}
|
||||
|
||||
async function removeResource() {
|
||||
const resource = currentResource($("#rl-resource-id").value);
|
||||
if (!resource || !confirm(`Delete “${resource.title}”? This also removes its retained PDF.`)) return;
|
||||
await Promise.all([deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]);
|
||||
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
|
||||
currentVault().updatedAt = Model.now();
|
||||
await saveLibrary();
|
||||
closeDialog(els.resourceDialog);
|
||||
render();
|
||||
toast("Resource deleted.");
|
||||
}
|
||||
|
||||
function openSessionDialog() {
|
||||
const resource = currentResource($("#rl-resource-id").value);
|
||||
if (!resource) return;
|
||||
const labels = resource.type === "book" ? ["Start page", "End page", "pages"] : resource.type === "video" ? ["Start minute", "End minute", "minutes"] : ["Start percentage", "End percentage", "percent"];
|
||||
$("#rl-session-start-label").textContent = labels[0];
|
||||
$("#rl-session-end-label").textContent = labels[1];
|
||||
$("#rl-session-form").dataset.unit = labels[2];
|
||||
$("#rl-session-form").reset();
|
||||
$("#rl-session-date").value = new Date().toISOString().slice(0, 10);
|
||||
const progress = Model.progressFor(resource);
|
||||
$("#rl-session-start").value = progress.current || 0;
|
||||
els.sessionDialog.showModal();
|
||||
}
|
||||
|
||||
async function saveSession(event) {
|
||||
event.preventDefault();
|
||||
const resource = currentResource($("#rl-resource-id").value);
|
||||
if (!resource) return;
|
||||
const start = Number($("#rl-session-start").value);
|
||||
const end = Number($("#rl-session-end").value);
|
||||
if (end < start) { toast("The end position must be at or after the start.", true); return; }
|
||||
if (resource.type === "article" && end > 100) { toast("Article progress cannot exceed 100%.", true); return; }
|
||||
resource.sessions.push({ id: Model.id(), date: $("#rl-session-date").value, start, end, unit: event.currentTarget.dataset.unit, note: $("#rl-session-note").value.trim(), createdAt: Model.now() });
|
||||
resource.updatedAt = Model.now();
|
||||
if (Model.progressFor(resource).percent >= 100) resource.status = "completed";
|
||||
else if (["backlog", "shelf"].includes(resource.status)) resource.status = "active";
|
||||
await saveLibrary();
|
||||
closeDialog(els.sessionDialog);
|
||||
renderSessions(resource);
|
||||
render();
|
||||
toast("Session logged.");
|
||||
}
|
||||
|
||||
async function openLinkedResource() {
|
||||
const resource = currentResource($("#rl-resource-id").value);
|
||||
if (!resource) return;
|
||||
if (resource.attachmentId) {
|
||||
const record = await getAttachment(resource.attachmentId);
|
||||
if (!record || !record.blob) { toast("This PDF is not attached in this browser. Import it again to restore the file.", true); return; }
|
||||
if (state.pdfUrls.has(resource.attachmentId)) URL.revokeObjectURL(state.pdfUrls.get(resource.attachmentId));
|
||||
const url = URL.createObjectURL(record.blob);
|
||||
state.pdfUrls.set(resource.attachmentId, url);
|
||||
window.open(url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
if (resource.url) window.open(resource.url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
function openNameDialog(action, title, value, colour) {
|
||||
state.nameAction = action;
|
||||
$("#rl-name-title").textContent = title;
|
||||
$("#rl-name-input").value = value || "";
|
||||
$("#rl-colour-field").hidden = !String(action).includes("vault");
|
||||
$("#rl-name-colour").value = colour || "#9b6b43";
|
||||
$("#rl-name-delete").hidden = !String(action).startsWith("rename-folder:");
|
||||
els.nameDialog.showModal();
|
||||
$("#rl-name-input").focus();
|
||||
}
|
||||
|
||||
async function submitName(event) {
|
||||
event.preventDefault();
|
||||
const name = $("#rl-name-input").value.trim();
|
||||
if (!name) return;
|
||||
const action = state.nameAction || "";
|
||||
if (action === "add-folder") state.library = Model.addFolder(state.library, state.vaultId, name);
|
||||
if (action.startsWith("rename-folder:")) {
|
||||
const folder = currentVault().folders.find((item) => item.id === action.split(":")[1]);
|
||||
folder.name = name; folder.updatedAt = Model.now(); currentVault().updatedAt = Model.now();
|
||||
}
|
||||
if (action === "add-vault") {
|
||||
state.library = Model.addVault(state.library, name, $("#rl-name-colour").value);
|
||||
state.vaultId = state.library.vaults[state.library.vaults.length - 1].id;
|
||||
state.folderId = "all";
|
||||
}
|
||||
if (action.startsWith("rename-vault:")) {
|
||||
state.library = Model.renameVault(state.library, action.split(":")[1], name);
|
||||
const vault = state.library.vaults.find((item) => item.id === action.split(":")[1]);
|
||||
vault.colour = $("#rl-name-colour").value;
|
||||
}
|
||||
await saveLibrary();
|
||||
closeDialog(els.nameDialog);
|
||||
render();
|
||||
renderVaultManager();
|
||||
toast("Saved.");
|
||||
}
|
||||
|
||||
function manageFolder(folderId) {
|
||||
const folder = currentVault().folders.find((item) => item.id === folderId);
|
||||
if (!folder) return;
|
||||
openNameDialog(`rename-folder:${folderId}`, "Manage folder", folder.name);
|
||||
}
|
||||
|
||||
async function deleteNamedFolder() {
|
||||
const action = state.nameAction || "";
|
||||
if (!action.startsWith("rename-folder:")) return;
|
||||
const folderId = action.split(":")[1];
|
||||
const folder = currentVault().folders.find((item) => item.id === folderId);
|
||||
if (!folder || !confirm(`Delete “${folder.name}”? Its resources will move to Unfiled.`)) return;
|
||||
state.library = Model.deleteFolder(state.library, state.vaultId, folderId);
|
||||
await saveLibrary();
|
||||
closeDialog(els.nameDialog);
|
||||
render();
|
||||
toast("Folder deleted; resources moved to Unfiled.");
|
||||
}
|
||||
|
||||
async function cleanupPendingImport() {
|
||||
const pending = state.pendingImport;
|
||||
if (!pending) return;
|
||||
state.pendingImport = null;
|
||||
await Promise.all([deleteAttachment(pending.attachmentId), deleteAttachment(pending.thumbnailId)]).catch(() => {});
|
||||
}
|
||||
|
||||
function renderVaultManager() {
|
||||
$("#rl-vault-list").innerHTML = state.library.vaults.map((vault) => `<div class="rl-vault-row">
|
||||
<span class="rl-vault-dot" style="--vault-colour:${escapeHtml(vault.colour)}"></span><strong>${escapeHtml(vault.name)}</strong>
|
||||
<button type="button" data-rename-vault="${escapeHtml(vault.id)}">Rename</button>
|
||||
<button type="button" class="rl-danger" data-delete-vault="${escapeHtml(vault.id)}" ${state.library.vaults.length === 1 ? "disabled" : ""}>Delete</button>
|
||||
</div>`).join("");
|
||||
$("#rl-vault-list").querySelectorAll("[data-rename-vault]").forEach((button) => button.addEventListener("click", () => {
|
||||
const vault = state.library.vaults.find((item) => item.id === button.dataset.renameVault);
|
||||
closeDialog(els.vaultDialog);
|
||||
openNameDialog(`rename-vault:${vault.id}`, "Rename vault", vault.name, vault.colour);
|
||||
}));
|
||||
$("#rl-vault-list").querySelectorAll("[data-delete-vault]").forEach((button) => button.addEventListener("click", async () => {
|
||||
const vault = state.library.vaults.find((item) => item.id === button.dataset.deleteVault);
|
||||
if (!confirm(`Delete the “${vault.name}” vault and all of its records? Retained PDFs will also be removed.`)) return;
|
||||
await Promise.all(vault.resources.flatMap((resource) => [deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]));
|
||||
state.library = Model.deleteVault(state.library, vault.id);
|
||||
state.vaultId = state.library.vaults[0].id;
|
||||
state.folderId = "all";
|
||||
await saveLibrary();
|
||||
renderVaultManager(); render(); toast("Vault deleted.");
|
||||
}));
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
const blob = new Blob([Model.serialise(state.library)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `resource-loader-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
toast("JSON backup exported. PDF files are not included.");
|
||||
}
|
||||
|
||||
async function readJsonFile(file) {
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
Model.validateLibrary(data);
|
||||
state.importData = Model.normaliseLibrary(data);
|
||||
const vaults = state.importData.vaults.length;
|
||||
const folders = state.importData.vaults.reduce((sum, vault) => sum + vault.folders.length, 0);
|
||||
const resources = state.importData.vaults.reduce((sum, vault) => sum + vault.resources.length, 0);
|
||||
$("#rl-import-summary").innerHTML = `<div><strong>${vaults}</strong><span>vaults</span></div><div><strong>${folders}</strong><span>folders</span></div><div><strong>${resources}</strong><span>resources</span></div>`;
|
||||
els.importDialog.showModal();
|
||||
} catch (error) {
|
||||
toast(`Import rejected: ${error.message}`, true);
|
||||
} finally {
|
||||
els.jsonInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function applyImport(mode) {
|
||||
if (!state.importData) return;
|
||||
if (mode === "replace" && !confirm("Replace every vault and resource? Retained PDF files will also be cleared.")) return;
|
||||
if (mode === "replace") await request(ATTACHMENT_STORE, "readwrite", (store) => store.clear());
|
||||
state.library = mode === "merge" ? Model.mergeLibraries(state.library, state.importData) : state.importData;
|
||||
state.vaultId = state.library.vaults[0] && state.library.vaults[0].id;
|
||||
state.folderId = "all";
|
||||
await saveLibrary();
|
||||
state.importData = null;
|
||||
closeDialog(els.importDialog);
|
||||
render();
|
||||
toast(mode === "merge" ? "Library merged." : "Library replaced.");
|
||||
}
|
||||
|
||||
function cleanPdfTitle(filename) {
|
||||
return filename.replace(/\.pdf$/i, "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
async function pdfCover(page) {
|
||||
const viewport = page.getViewport({ scale: 0.7 });
|
||||
const canvas = document.createElement("canvas");
|
||||
const maximum = 420;
|
||||
const scale = Math.min(1, maximum / viewport.width);
|
||||
const scaled = page.getViewport({ scale: 0.7 * scale });
|
||||
canvas.width = Math.ceil(scaled.width);
|
||||
canvas.height = Math.ceil(scaled.height);
|
||||
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", .78));
|
||||
}
|
||||
|
||||
async function importPdf(file) {
|
||||
if (!file || (file.type && file.type !== "application/pdf") || !file.name.toLowerCase().endsWith(".pdf")) {
|
||||
toast("Choose a PDF file.", true); return;
|
||||
}
|
||||
showLoading("Reading PDF metadata…");
|
||||
let attachmentId;
|
||||
let thumbnailId;
|
||||
try {
|
||||
const pdfjs = await import("/assets/scripts/vendor/pdf.min.mjs");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/assets/scripts/vendor/pdf.worker.min.mjs";
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const documentTask = pdfjs.getDocument({ data: bytes });
|
||||
const pdf = await documentTask.promise;
|
||||
const metadata = await pdf.getMetadata().catch(() => ({ info: {}, metadata: null }));
|
||||
const info = metadata.info || {};
|
||||
attachmentId = Model.id();
|
||||
await putAttachment({ id: attachmentId, kind: "pdf", name: file.name, type: file.type || "application/pdf", size: file.size, blob: file, createdAt: Model.now() });
|
||||
const cover = await pdfCover(await pdf.getPage(1)).catch(() => null);
|
||||
if (cover) {
|
||||
thumbnailId = Model.id();
|
||||
await putAttachment({ id: thumbnailId, kind: "thumbnail", name: `${file.name}.jpg`, type: "image/jpeg", size: cover.size, blob: cover, createdAt: Model.now() });
|
||||
}
|
||||
const keywords = Array.isArray(info.Keywords) ? info.Keywords : splitList(info.Keywords || "");
|
||||
const draft = {
|
||||
title: String(info.Title || "").trim() || cleanPdfTitle(file.name),
|
||||
creators: splitList(info.Author || ""),
|
||||
type: "book", format: "pdf", status: "shelf", pageCount: pdf.numPages,
|
||||
description: String(info.Subject || "").trim(), tags: keywords,
|
||||
publisher: String(info.Producer || info.Creator || "").trim(),
|
||||
published: String(info.CreationDate || "").replace(/^D:/, "").slice(0, 8),
|
||||
notes: "", attachmentId, thumbnailId: thumbnailId || null,
|
||||
};
|
||||
await pdf.destroy();
|
||||
hideLoading();
|
||||
await openResourceDialog(null, draft);
|
||||
toast("PDF metadata loaded. Review it before saving.");
|
||||
} catch (error) {
|
||||
hideLoading();
|
||||
if (attachmentId) await deleteAttachment(attachmentId).catch(() => {});
|
||||
if (thumbnailId) await deleteAttachment(thumbnailId).catch(() => {});
|
||||
const reason = error && error.name === "PasswordException" ? "The PDF is password protected." : error && error.name === "QuotaExceededError" ? "Browser storage is full." : "The PDF could not be read.";
|
||||
toast(`${reason} No resource was created.`, true);
|
||||
console.error(error);
|
||||
} finally {
|
||||
els.pdfInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function showStorage() {
|
||||
els.storageDialog.showModal();
|
||||
if (navigator.storage && navigator.storage.estimate) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
const used = estimate.usage || 0;
|
||||
const quota = estimate.quota || 0;
|
||||
const format = (bytes) => bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
$("#rl-storage-summary").textContent = `${format(used)} used of approximately ${format(quota)} available to this site.`;
|
||||
} else $("#rl-storage-summary").textContent = "This browser does not report storage estimates.";
|
||||
}
|
||||
|
||||
async function requestPersistence() {
|
||||
if (!navigator.storage || !navigator.storage.persist) { toast("Persistent storage is not supported by this browser.", true); return; }
|
||||
const granted = await navigator.storage.persist();
|
||||
toast(granted ? "Persistent storage granted." : "The browser did not grant persistent storage.", !granted);
|
||||
}
|
||||
|
||||
async function clearData() {
|
||||
if (!confirm("Clear every Resource Loader vault, record, session, and retained PDF from this browser?")) return;
|
||||
closeDialog(els.storageDialog);
|
||||
state.db.close();
|
||||
await new Promise((resolve, reject) => {
|
||||
const deletion = indexedDB.deleteDatabase(DB_NAME);
|
||||
deletion.onsuccess = resolve; deletion.onerror = () => reject(deletion.error); deletion.onblocked = resolve;
|
||||
});
|
||||
localStorage.removeItem("rl-view");
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
els.vaultSelect.addEventListener("change", () => { state.vaultId = els.vaultSelect.value; state.folderId = "all"; render(); });
|
||||
[els.search, els.typeFilter, els.statusFilter, els.tagFilter, els.sort].forEach((control) => control.addEventListener(control === els.search ? "input" : "change", renderResources));
|
||||
$("#rl-view-list").addEventListener("click", () => { state.view = "list"; localStorage.setItem("rl-view", state.view); renderResources(); });
|
||||
$("#rl-view-grid").addEventListener("click", () => { state.view = "grid"; localStorage.setItem("rl-view", state.view); renderResources(); });
|
||||
$("#rl-add-resource").addEventListener("click", () => openResourceDialog());
|
||||
$("#rl-add-folder").addEventListener("click", () => openNameDialog("add-folder", "Add folder"));
|
||||
$("#rl-manage-vaults").addEventListener("click", () => { renderVaultManager(); els.vaultDialog.showModal(); });
|
||||
$("#rl-add-vault").addEventListener("click", () => { closeDialog(els.vaultDialog); openNameDialog("add-vault", "Add vault", "", "#9b6b43"); });
|
||||
els.resourceForm.addEventListener("submit", saveResource);
|
||||
$("#rl-delete-resource").addEventListener("click", removeResource);
|
||||
$("#rl-open-resource").addEventListener("click", openLinkedResource);
|
||||
$("#rl-add-session").addEventListener("click", openSessionDialog);
|
||||
$("#rl-session-form").addEventListener("submit", saveSession);
|
||||
$("#rl-name-form").addEventListener("submit", submitName);
|
||||
$("#rl-name-delete").addEventListener("click", deleteNamedFolder);
|
||||
$("#rl-import-pdf").addEventListener("click", () => els.pdfInput.click());
|
||||
els.pdfInput.addEventListener("change", () => importPdf(els.pdfInput.files[0]));
|
||||
$("#rl-import-json").addEventListener("click", () => els.jsonInput.click());
|
||||
els.jsonInput.addEventListener("change", () => readJsonFile(els.jsonInput.files[0]));
|
||||
$("#rl-export-json").addEventListener("click", exportJson);
|
||||
$("#rl-import-merge").addEventListener("click", () => applyImport("merge"));
|
||||
$("#rl-import-replace").addEventListener("click", () => applyImport("replace"));
|
||||
$("#rl-storage-button").addEventListener("click", showStorage);
|
||||
$("#rl-request-persistence").addEventListener("click", requestPersistence);
|
||||
$("#rl-clear-data").addEventListener("click", clearData);
|
||||
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => closeDialog(button.closest("dialog"))));
|
||||
$$('dialog').forEach((dialog) => dialog.addEventListener("click", (event) => { if (event.target === dialog) closeDialog(dialog); }));
|
||||
els.resourceDialog.addEventListener("close", cleanupPendingImport);
|
||||
window.addEventListener("beforeunload", () => {
|
||||
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
});
|
||||
}
|
||||
|
||||
async function initialise() {
|
||||
showLoading("Opening your library…");
|
||||
try {
|
||||
state.db = await openDatabase();
|
||||
state.library = await loadLibrary();
|
||||
if (!state.library.vaults.length) state.library = Model.createSeed();
|
||||
state.vaultId = state.library.vaults[0].id;
|
||||
await saveLibrary();
|
||||
bindEvents();
|
||||
render();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
root.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">!</span><h3>Library storage is unavailable</h3><p>${escapeHtml(error.message || "This browser could not open IndexedDB.")}</p></div>`;
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
initialise();
|
||||
})();
|
||||
Reference in New Issue
Block a user