Add Resource Loader SPA and JS test harness
All checks were successful
Build Org Website / build (push) Successful in 38s

This commit is contained in:
gitea-actions
2026-08-19 15:03:41 +01:00
parent 75af3e1f0e
commit b8bb1944dd
22 changed files with 2111 additions and 206 deletions

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"vaults": [
{
"id": "vault-work",
"name": "Work",
"colour": "#526d82",
"folders": [],
"resources": [],
"createdAt": "2026-08-19T00:00:00.000Z",
"updatedAt": "2026-08-19T00:00:00.000Z"
},
{
"id": "vault-personal-study",
"name": "Personal Study",
"colour": "#9b6b43",
"folders": [],
"resources": [],
"createdAt": "2026-08-19T00:00:00.000Z",
"updatedAt": "2026-08-19T00:00:00.000Z"
}
]
}

View 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,
};
});

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
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();
})();

28
assets/scripts/vendor/pdf.min.mjs vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

177
assets/scripts/vendor/pdfjs-LICENSE.txt vendored Normal file
View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -194,7 +194,8 @@
}
.db-qn-item--writing,
.db-qn-item--notes { border-left-color: var(--db-blue); }
.db-qn-item--notes,
.db-qn-item--reading { border-left-color: var(--db-blue); }
.db-qn-item--career,
.db-qn-item--wird { border-left-color: var(--db-green); }
.db-qn-item--lima,

View File

@@ -0,0 +1,385 @@
.rl-app,
.rl-dialog {
--rl-ink: var(--fg);
--rl-paper: color-mix(in oklab, var(--surface) 94%, #efe5d4 6%);
--rl-paper-deep: color-mix(in oklab, var(--surface-soft) 82%, #d9bea0 18%);
--rl-line: color-mix(in oklab, var(--border) 80%, #9b6b43 20%);
--rl-accent: color-mix(in oklab, var(--accent) 54%, #9b6b43 46%);
--rl-accent-strong: color-mix(in oklab, var(--accent) 36%, #744729 64%);
--rl-muted: var(--muted);
font-family: var(--font-body);
}
.rl-app [hidden],
.rl-dialog [hidden] {
display: none !important;
}
.rl-app {
min-height: 72vh;
color: var(--rl-ink);
background:
radial-gradient(circle at 92% 4%, color-mix(in oklab, var(--rl-accent) 12%, transparent) 0, transparent 28rem),
linear-gradient(180deg, color-mix(in oklab, var(--rl-paper) 86%, transparent), transparent 36rem);
}
.rl-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
padding: 2.25rem 2rem 1.7rem;
border: 1px solid var(--rl-line);
border-radius: 18px 18px 0 0;
background: var(--rl-paper);
box-shadow: 0 18px 50px color-mix(in oklab, #000 8%, transparent);
}
.rl-hero h1,
.rl-library h2,
.rl-sidebar h2,
.rl-dialog h2,
.rl-dialog h3 {
font-family: Georgia, "Times New Roman", serif;
color: var(--heading);
margin: 0;
}
.rl-hero h1 {
font-size: clamp(2.1rem, 5vw, 4.25rem);
line-height: .95;
letter-spacing: -.04em;
}
.rl-hero__copy > p:last-child {
max-width: 42rem;
margin: .8rem 0 0;
color: var(--rl-muted);
}
.rl-eyebrow {
margin: 0 0 .45rem;
color: var(--rl-accent-strong);
font-size: .72rem;
font-weight: 850;
letter-spacing: .14em;
text-transform: uppercase;
}
.rl-hero__actions {
display: flex;
align-items: flex-end;
gap: .6rem;
min-width: min(100%, 22rem);
}
.rl-vault-picker {
display: grid;
gap: .35rem;
flex: 1;
color: var(--rl-muted);
font-size: .72rem;
font-weight: 750;
letter-spacing: .08em;
text-transform: uppercase;
}
.rl-app button,
.rl-app input,
.rl-app select,
.rl-dialog button,
.rl-dialog input,
.rl-dialog select,
.rl-dialog textarea {
box-sizing: border-box;
border: 1px solid var(--rl-line);
border-radius: 9px;
color: var(--rl-ink);
background: var(--surface);
font: inherit;
}
.rl-app button,
.rl-dialog button {
min-height: 2.55rem;
padding: .55rem .85rem;
font-size: .86rem;
font-weight: 800;
cursor: pointer;
}
.rl-app button:hover,
.rl-dialog button:hover,
.rl-app button:focus-visible,
.rl-dialog button:focus-visible {
border-color: var(--rl-accent);
background: color-mix(in oklab, var(--rl-accent) 9%, var(--surface));
}
.rl-app :focus-visible,
.rl-dialog :focus-visible {
outline: 3px solid color-mix(in oklab, var(--rl-accent) 38%, transparent);
outline-offset: 2px;
}
.rl-app .rl-primary,
.rl-dialog .rl-primary {
border-color: var(--rl-accent-strong);
color: #fffaf2;
background: var(--rl-accent-strong);
}
.rl-app .rl-primary:hover,
.rl-dialog .rl-primary:hover {
color: white;
background: color-mix(in oklab, var(--rl-accent-strong) 88%, #000);
}
.rl-icon-button { white-space: nowrap; }
.rl-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(8rem, 1.4fr);
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
background: var(--rl-paper-deep);
}
.rl-summary > div {
display: flex;
align-items: baseline;
gap: .45rem;
padding: .9rem 1.15rem;
border-right: 1px solid var(--rl-line);
}
.rl-summary > div:last-child { border-right: 0; }
.rl-summary span { font-family: Georgia, serif; font-size: 1.55rem; font-weight: 800; color: var(--heading); }
.rl-summary small { color: var(--rl-muted); font-size: .72rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
.rl-summary .rl-summary__privacy { justify-content: flex-end; }
.rl-summary__privacy span { color: #4a8a5b; font-size: .8rem; }
.rl-command-bar {
display: flex;
flex-wrap: wrap;
gap: .55rem;
padding: 1rem;
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
background: color-mix(in oklab, var(--surface) 88%, transparent);
}
.rl-workspace {
display: grid;
grid-template-columns: 15.5rem minmax(0, 1fr);
min-height: 36rem;
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
border-radius: 0 0 18px 18px;
overflow: hidden;
background: var(--rl-paper);
}
.rl-sidebar {
padding: 1.3rem .9rem;
border-right: 1px solid var(--rl-line);
background: color-mix(in oklab, var(--rl-paper-deep) 72%, var(--surface));
}
.rl-sidebar__head,
.rl-library__toolbar,
.rl-sessions__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.rl-sidebar h2,
.rl-library h2 { font-size: 1.4rem; }
.rl-folder-list {
display: grid;
gap: .25rem;
margin-top: 1rem;
}
.rl-folder-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem; }
.rl-folder-row > button:first-child {
display: flex;
justify-content: space-between;
align-items: center;
gap: .5rem;
width: 100%;
border-color: transparent;
text-align: left;
background: transparent;
}
.rl-folder-row > button:first-child span:last-child { color: var(--rl-muted); font-size: .75rem; }
.rl-folder-row.is-active > button:first-child { border-color: var(--rl-line); color: var(--heading); background: var(--surface); box-shadow: 0 5px 12px color-mix(in oklab, #000 6%, transparent); }
.rl-folder-menu { min-width: 2.45rem; padding-inline: .55rem !important; }
.rl-folder-rule { height: 1px; margin: .55rem .4rem; background: var(--rl-line); }
.rl-library { min-width: 0; padding: 1.35rem; }
.rl-library__toolbar { margin-bottom: 1rem; }
.rl-view-toggle { display: flex; gap: .2rem; padding: .2rem; border: 1px solid var(--rl-line); border-radius: 10px; }
.rl-view-toggle button { min-height: 2rem; padding: .3rem .65rem; border-color: transparent; background: transparent; }
.rl-view-toggle button.is-active { background: var(--rl-paper-deep); color: var(--heading); }
.rl-filters {
display: grid;
grid-template-columns: minmax(12rem, 2fr) repeat(4, minmax(7rem, 1fr));
gap: .55rem;
margin-bottom: 1rem;
}
.rl-filters input,
.rl-filters select,
.rl-vault-picker select { width: 100%; min-height: 2.65rem; padding: .55rem .7rem; }
.rl-resource-list { display: grid; gap: .6rem; }
.rl-resource-list.is-grid { grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr)); }
.rl-resource-card {
display: grid;
grid-template-columns: 3.5rem minmax(0, 1.25fr) minmax(8rem, .8fr) minmax(8rem, .65fr) auto;
align-items: center;
gap: .9rem;
width: 100%;
padding: .65rem;
border: 1px solid var(--rl-line);
border-radius: 12px;
color: var(--rl-ink);
background: var(--surface);
box-shadow: 0 5px 14px color-mix(in oklab, #000 5%, transparent);
text-align: left;
cursor: pointer;
}
.rl-resource-card:hover { transform: translateY(-1px); border-color: var(--rl-accent); }
.rl-resource-cover {
display: grid;
place-items: center;
width: 3.5rem;
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: 7px;
color: color-mix(in oklab, var(--rl-accent-strong) 70%, white);
background: linear-gradient(135deg, var(--rl-accent-strong), color-mix(in oklab, var(--rl-accent) 40%, var(--surface)));
font-family: Georgia, serif;
font-size: 1.3rem;
font-weight: 800;
}
.rl-resource-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: 0 !important; }
.rl-resource-title { min-width: 0; }
.rl-resource-title strong { display: block; overflow: hidden; color: var(--heading); font-family: Georgia, serif; font-size: 1rem; text-overflow: ellipsis; white-space: nowrap; }
.rl-resource-title small,
.rl-resource-meta { display: block; margin-top: .25rem; color: var(--rl-muted); font-size: .76rem; }
.rl-badges { display: flex; flex-wrap: wrap; gap: .3rem; }
.rl-badge { padding: .22rem .42rem; border-radius: 999px; color: var(--rl-accent-strong); background: color-mix(in oklab, var(--rl-accent) 13%, var(--surface)); font-size: .68rem; font-weight: 800; text-transform: capitalize; }
.rl-progress { min-width: 7rem; }
.rl-progress__track { height: .42rem; overflow: hidden; border-radius: 999px; background: var(--rl-paper-deep); }
.rl-progress__fill { display: block; height: 100%; border-radius: inherit; background: var(--rl-accent-strong); }
.rl-progress small { display: block; margin-top: .3rem; color: var(--rl-muted); font-size: .7rem; text-align: right; }
.rl-card-arrow { color: var(--rl-muted); font-size: 1.2rem; }
.rl-resource-list.is-grid .rl-resource-card { grid-template-columns: 3.8rem minmax(0, 1fr); align-items: start; min-height: 9rem; }
.rl-resource-list.is-grid .rl-badges,
.rl-resource-list.is-grid .rl-progress { grid-column: 1 / -1; }
.rl-resource-list.is-grid .rl-card-arrow { display: none; }
.rl-empty { padding: 4rem 1.5rem; border: 1px dashed var(--rl-line); border-radius: 14px; text-align: center; background: color-mix(in oklab, var(--surface) 58%, transparent); }
.rl-empty__mark { display: block; margin-bottom: .8rem; color: var(--rl-accent); font-family: Georgia, serif; font-size: 3rem; }
.rl-empty h3 { margin: 0; color: var(--heading); font-family: Georgia, serif; }
.rl-empty p { max-width: 32rem; margin: .5rem auto 1rem; color: var(--rl-muted); }
.rl-dialog { width: min(60rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); padding: 0; border: 1px solid var(--rl-line); border-radius: 16px; color: var(--rl-ink); background: var(--surface); box-shadow: 0 24px 80px rgba(0,0,0,.3); }
.rl-dialog::backdrop { background: rgba(12, 10, 8, .62); backdrop-filter: blur(3px); }
.rl-dialog__shell { display: flex; flex-direction: column; max-height: calc(100vh - 2rem); }
.rl-dialog__shell--small { width: min(32rem, 100%); margin: auto; }
.rl-dialog__head,
.rl-dialog__foot { display: flex; align-items: center; gap: .6rem; padding: 1rem 1.2rem; border-bottom: 1px solid var(--rl-line); background: var(--rl-paper); }
.rl-dialog__head { justify-content: space-between; }
.rl-dialog__foot { justify-content: flex-end; border-top: 1px solid var(--rl-line); border-bottom: 0; }
.rl-dialog__body { overflow: auto; padding: 1.2rem; }
.rl-dialog__spacer { flex: 1; }
.rl-close { width: 2.55rem; padding: 0 !important; font-size: 1.45rem !important; }
.rl-danger { color: #a12d2d !important; border-color: color-mix(in oklab, #a12d2d 45%, var(--rl-line)) !important; }
.rl-resource-dialog .rl-dialog__body { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: 1.2rem; }
.rl-cover-preview { display: grid; place-items: center; align-self: start; aspect-ratio: 3/4; overflow: hidden; border: 1px solid var(--rl-line); border-radius: 10px; color: var(--rl-muted); background: var(--rl-paper-deep); font-family: Georgia, serif; }
.rl-cover-preview img { width: 100%; height: 100%; object-fit: cover; }
.rl-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .85rem; }
.rl-field { display: grid; gap: .35rem; color: var(--rl-muted); font-size: .78rem; font-weight: 750; }
.rl-field--wide { grid-column: 1 / -1; }
.rl-field input,
.rl-field select,
.rl-field textarea { width: 100%; min-height: 2.55rem; padding: .55rem .65rem; resize: vertical; color: var(--rl-ink); }
.rl-sessions { grid-column: 1 / -1; padding-top: 1rem; border-top: 1px solid var(--rl-line); }
.rl-session-row { display: grid; grid-template-columns: 7rem minmax(8rem, auto) minmax(0, 1fr) auto; gap: .7rem; align-items: center; padding: .55rem 0; border-bottom: 1px solid var(--rl-line); font-size: .82rem; }
.rl-session-row small { color: var(--rl-muted); }
.rl-session-row button { min-height: 2rem; padding: .25rem .55rem; }
.rl-vault-row { display: grid; grid-template-columns: .8rem minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; padding: .65rem 0; border-bottom: 1px solid var(--rl-line); }
.rl-vault-dot { width: .7rem; height: .7rem; border-radius: 50%; background: var(--vault-colour); }
.rl-import-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
.rl-import-summary div { padding: .8rem; border-radius: 10px; background: var(--rl-paper-deep); text-align: center; }
.rl-import-summary strong { display: block; color: var(--heading); font-family: Georgia, serif; font-size: 1.6rem; }
.rl-help { color: var(--rl-muted); font-size: .82rem; }
.rl-loading { display: none; position: fixed; inset: 0; z-index: 1000; place-items: center; align-content: center; color: white; background: rgba(12, 10, 8, .68); }
.rl-loading.is-visible { display: grid; }
.rl-loading span { width: 2.3rem; height: 2.3rem; border: 3px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: rl-spin .8s linear infinite; }
.rl-loading p { margin: .8rem 0; }
@keyframes rl-spin { to { transform: rotate(360deg); } }
.rl-toast { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 1100; max-width: min(25rem, calc(100vw - 2.4rem)); padding: .8rem 1rem; border: 1px solid var(--rl-line); border-radius: 10px; color: var(--rl-ink); background: var(--surface); box-shadow: 0 16px 45px rgba(0,0,0,.22); opacity: 0; transform: translateY(1rem); pointer-events: none; transition: .2s ease; }
.rl-toast.is-visible { opacity: 1; transform: translateY(0); }
.rl-toast.is-error { border-color: #a12d2d; }
@media (max-width: 900px) {
.rl-hero { align-items: stretch; flex-direction: column; }
.rl-summary { grid-template-columns: repeat(4, 1fr); }
.rl-summary__privacy { grid-column: 1 / -1; justify-content: flex-start !important; border-top: 1px solid var(--rl-line); }
.rl-workspace { grid-template-columns: 1fr; }
.rl-sidebar { border-right: 0; border-bottom: 1px solid var(--rl-line); }
.rl-folder-list { display: flex; overflow-x: auto; padding-bottom: .25rem; }
.rl-folder-row { flex: 0 0 auto; }
.rl-folder-row > button:first-child { width: auto; }
.rl-folder-rule { width: 1px; height: auto; margin: .3rem; }
.rl-filters { grid-template-columns: repeat(4, 1fr); }
.rl-search { grid-column: 1 / -1; }
.rl-resource-card { grid-template-columns: 3.5rem minmax(0, 1fr) auto; }
.rl-resource-card .rl-badges,
.rl-resource-card .rl-progress { grid-column: 2 / -1; }
}
@media (max-width: 620px) {
.rl-hero { padding: 1.4rem 1rem; border-radius: 12px 12px 0 0; }
.rl-hero__actions { align-items: stretch; flex-direction: column; min-width: 0; }
.rl-summary { grid-template-columns: repeat(2, 1fr); }
.rl-summary > div:nth-child(2) { border-right: 0; }
.rl-summary > div:nth-child(-n+2) { border-bottom: 1px solid var(--rl-line); }
.rl-command-bar button { flex: 1 1 8rem; }
.rl-library { padding: 1rem .7rem; }
.rl-library__toolbar { align-items: flex-start; }
.rl-filters { grid-template-columns: 1fr; }
.rl-search { grid-column: auto; }
.rl-resource-list.is-grid { grid-template-columns: 1fr; }
.rl-resource-card { grid-template-columns: 3.5rem minmax(0, 1fr); }
.rl-resource-card .rl-badges,
.rl-resource-card .rl-progress { grid-column: 1 / -1; }
.rl-card-arrow { display: none; }
.rl-resource-dialog .rl-dialog__body { grid-template-columns: 1fr; }
.rl-cover-preview { width: 7rem; }
.rl-form-grid { grid-template-columns: 1fr; }
.rl-field--wide { grid-column: auto; }
.rl-dialog__foot { flex-wrap: wrap; }
.rl-dialog__spacer { display: none; }
.rl-session-row { grid-template-columns: 1fr auto; }
.rl-session-row span:nth-child(3) { grid-column: 1 / -1; }
}
@media (prefers-reduced-motion: reduce) {
.rl-resource-card,
.rl-toast { transition: none; }
.rl-loading span { animation: none; }
}