- storage.ts: Centralize ALL_LIBRARIES = "__all__" sentinel constant. setSelectedLibrary() now writes both localStorage and a cookie (selectedLibrary, Path=/, SameSite=Lax, max-age=365d). The sentinel "__all__" is used in both storage mediums — empty strings are never stored. getSelectedLibrary() maps __all__ back to "". Cookie enables server-side rendering to read the stored library selection without access to localStorage. - library-switcher.ts: Import ALL_LIBRARIES and setSelectedLibrary/ getSelectedLibrary from storage.ts instead of managing localStorage directly. Remove local constants. - dashboard.ts: Remove duplicate localStorage.setItem call that was overwriting the __all__ sentinel with raw empty string. Fix reloadPage() and scan-complete handler to work with empty libraryId. openDashboardSettings/saveDashboardSettings show clear messages for All Libraries mode. - collections.ts: Remove library switcher initialization from the collections list page — the list page no longer has a switcher. - series.ts: Rewrite to use initLibrarySwitcher from library-switcher module and switchWithTransition for navigation. Series card links no longer include library_id in their URLs. - bookshelf.ts: Autocomplete fetch calls handle empty libraryId correctly for All Libraries mode. - search.ts, collection-rules.ts: Use setSelectedLibrary() and getSelectedLibrary() from storage.ts instead of direct localStorage access.
594 lines
19 KiB
TypeScript
594 lines
19 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
import { createWebSocket } from "./websocket";
|
|
import {
|
|
getCurrentLibraryId,
|
|
initLibrarySwitcher,
|
|
switchWithTransition,
|
|
} from "./library-switcher";
|
|
|
|
let collectionId: string | null = null;
|
|
|
|
function initializeCollectionWebSocket(): void {
|
|
const dataEl = document.getElementById("collection-data");
|
|
if (!dataEl) return;
|
|
|
|
collectionId = dataEl.dataset.id || null;
|
|
|
|
if (!collectionId) return;
|
|
|
|
createWebSocket({
|
|
onMessage: (message) => {
|
|
if (
|
|
message.type === "collection_updated" &&
|
|
message.data.collection_id === collectionId
|
|
) {
|
|
const actionText =
|
|
message.data.action === "books_added"
|
|
? `Added ${message.data.count || 0} book(s)`
|
|
: message.data.action === "book_removed"
|
|
? "Removed a book"
|
|
: message.data.action === "books_bulk_removed"
|
|
? `Removed ${message.data.count || 0} book(s)`
|
|
: "Collection updated";
|
|
|
|
showToast(actionText, "info");
|
|
|
|
const activeElement = document.activeElement;
|
|
const isUserActive =
|
|
activeElement &&
|
|
(activeElement.tagName === "INPUT" ||
|
|
activeElement.tagName === "TEXTAREA" ||
|
|
activeElement.tagName === "SELECT" ||
|
|
activeElement.getAttribute("contenteditable") === "true");
|
|
|
|
if (!isUserActive) {
|
|
setTimeout(() => {
|
|
location.reload();
|
|
}, 1000);
|
|
}
|
|
}
|
|
},
|
|
enableReconnect: true,
|
|
reconnectDelay: 5000,
|
|
});
|
|
}
|
|
|
|
async function loadCollections(): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch("/api/collections", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
renderCollectionsGrid(data.collections || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load collections:", error);
|
|
}
|
|
}
|
|
|
|
function renderCollectionsGrid(collections: CollectionData[]): void {
|
|
const container = document.getElementById("collections-list");
|
|
if (!container) return;
|
|
|
|
if (collections.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
|
<div class="text-6xl mb-4">📚</div>
|
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
|
<p class="mb-4">Create collections to organize your books</p>
|
|
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML"
|
|
class="btn-primary px-4 py-2 rounded-lg">Create Your First Collection</button>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const libraryId = getCurrentLibraryId();
|
|
const libraryParam = libraryId ? `?library_id=${libraryId}` : "";
|
|
|
|
container.innerHTML = collections
|
|
.map(
|
|
(col) => `
|
|
<div @click="navigateToCollection($el)" data-href="/collections/${col.id}${libraryParam}" class="block">
|
|
<div class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
|
style="background-color: var(--bg-secondary);"
|
|
data-color="${col.color}">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div class="text-3xl">${col.icon}</div>
|
|
<div class="flex space-x-2">
|
|
<button hx-get="/collections/${col.id}/edit-modal" hx-target="#modal-container"
|
|
hx-swap="innerHTML" class="p-2 hover:opacity-80 rounded"
|
|
style="color: var(--text-secondary); background-color: var(--bg-primary);">✏️</button>
|
|
<button hx-delete="/api/collections/${col.id}" hx-redirect="/collections"
|
|
hx-confirm="Are you sure you want to delete this collection?"
|
|
class="p-2 hover:opacity-80 rounded"
|
|
style="color: var(--text-secondary); background-color: var(--bg-primary);">🗑️</button>
|
|
</div>
|
|
</div>
|
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">${col.name}</h3>
|
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">${col.description}</p>
|
|
${col.book_count > 0 ? `<p class="text-xs" style="color: var(--text-secondary)">${col.book_count} books</p>` : ""}
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
initColorSelection();
|
|
Alpine.initTree(container);
|
|
}
|
|
|
|
function renderCollectionBooks(books: BookInfo[]): void {
|
|
const container = document.getElementById("books-container");
|
|
if (!container) return;
|
|
|
|
if (books.length === 0) {
|
|
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = books
|
|
.map(
|
|
(book) => `
|
|
<a href="/media/${book.media_item_id}">
|
|
<div class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
|
|
style="background-color: var(--bg-secondary); border-color: var(--border);">
|
|
<div class="flex gap-4">
|
|
<div class="flex-shrink-0 pt-1">
|
|
<input type="checkbox" onchange="toggleBookForRemoval('${book.media_item_id}')" class="w-5 h-5"/>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
|
|
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">by ${book.author}</p>` : ""}
|
|
</div>
|
|
<div class="flex-shrink-0 w-16 sm:w-20">
|
|
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="Cover"
|
|
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
|
onerror="this.src='/static/placeholder-book.svg'"/>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
|
<button @click="removeBook('${book.media_item_id}')" class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
|
style="border-color: var(--border); color: var(--text-secondary);">🗑️ Remove from Collection</button>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
Alpine.initTree(container);
|
|
}
|
|
|
|
async function loadCollectionRules(collectionId: string): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const rules: CollectionRule[] = await response.json();
|
|
renderRules(rules);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load rules:", error);
|
|
}
|
|
}
|
|
|
|
function renderRules(rules: CollectionRule[]): void {
|
|
const container = document.getElementById("rules-list");
|
|
if (!container) return;
|
|
|
|
if (rules.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = rules
|
|
.map(
|
|
(rule) => `
|
|
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
|
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
<div>
|
|
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
|
|
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}</p>
|
|
</div>
|
|
<div class="flex space-x-2">
|
|
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
|
|
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function createRule(
|
|
collectionId: string,
|
|
rule: Partial<CollectionRule>,
|
|
): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(rule),
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast("Rule created", "success");
|
|
loadCollectionRules(collectionId);
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(error.error || "Failed to create rule", "error");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to create rule:", error);
|
|
showToast("Failed to create rule", "error");
|
|
}
|
|
}
|
|
|
|
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
if (!confirm("Are you sure you want to delete this rule?")) return;
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/collections/${collectionId}/rules/${ruleId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
|
|
if (response.ok) {
|
|
showToast("Rule deleted", "success");
|
|
loadCollectionRules(collectionId);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to delete rule:", error);
|
|
showToast("Failed to delete rule", "error");
|
|
}
|
|
}
|
|
|
|
async function testRule(
|
|
collectionId: string,
|
|
rule: Partial<CollectionRule>,
|
|
): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/collections/${collectionId}/rules/test`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(rule),
|
|
},
|
|
);
|
|
|
|
if (response.ok) {
|
|
const results = await response.json();
|
|
renderTestResults(results);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to test rule:", error);
|
|
showToast("Failed to test rule", "error");
|
|
}
|
|
}
|
|
|
|
function renderTestResults(results: unknown[]): void {
|
|
const container = document.getElementById("test-results");
|
|
if (!container) return;
|
|
|
|
if (!results || (Array.isArray(results) && results.length === 0)) {
|
|
container.innerHTML =
|
|
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`;
|
|
}
|
|
|
|
function setupHTMXAuth(): void {
|
|
document.body.addEventListener("htmx:configRequest", function (evt: Event) {
|
|
const token = localStorage.getItem("token");
|
|
(evt as any).detail.headers["Authorization"] = `Bearer ${token}`;
|
|
});
|
|
}
|
|
|
|
function navigateToCollection(element: HTMLElement): void {
|
|
const event = window.event as Event;
|
|
if (event && event.target instanceof HTMLElement) {
|
|
const target = event.target as HTMLElement;
|
|
if (target.tagName === "BUTTON" || target.closest("button") !== null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const href = element.getAttribute("data-href");
|
|
if (href) {
|
|
window.location.href = href;
|
|
}
|
|
}
|
|
|
|
const borderClasses: Record<string, string> = {
|
|
blue: "border-blue-500",
|
|
red: "border-red-500",
|
|
yellow: "border-yellow-500",
|
|
green: "border-green-500",
|
|
purple: "border-purple-500",
|
|
};
|
|
|
|
function selectColor(color: string): void {
|
|
const colorInput = document.getElementById(
|
|
"collection-color",
|
|
) as HTMLInputElement;
|
|
if (colorInput) {
|
|
colorInput.value = color;
|
|
}
|
|
document.querySelectorAll(".color-option").forEach((btn) => {
|
|
(btn as HTMLElement).style.outline = "none";
|
|
(btn as HTMLElement).style.outlineOffset = "0";
|
|
});
|
|
const selectedBtn = document.querySelector(
|
|
`.color-option[onclick="selectColor('${color}')"]`,
|
|
) as HTMLElement;
|
|
if (selectedBtn) {
|
|
selectedBtn.style.outline = "3px solid var(--text-primary)";
|
|
selectedBtn.style.outlineOffset = "3px";
|
|
}
|
|
}
|
|
|
|
function closeCollectionModal(): void {
|
|
const modal = document.querySelector(".fixed.inset-0");
|
|
if (modal) {
|
|
modal.remove();
|
|
}
|
|
}
|
|
|
|
function initColorSelection(): void {
|
|
const colorInput = document.getElementById(
|
|
"collection-color",
|
|
) as HTMLInputElement;
|
|
if (colorInput && colorInput.value) {
|
|
selectColor(colorInput.value);
|
|
}
|
|
|
|
document.querySelectorAll("[data-color]").forEach((card) => {
|
|
const color = (card as HTMLElement).getAttribute("data-color");
|
|
if (color && borderClasses[color]) {
|
|
Object.values(borderClasses).forEach((cls) => {
|
|
(card as HTMLElement).classList.remove(cls);
|
|
});
|
|
(card as HTMLElement).classList.add(borderClasses[color]);
|
|
}
|
|
});
|
|
}
|
|
|
|
function setupHTMXModalInit(): void {
|
|
document.body.addEventListener("htmx:afterSwap", function (evt: CustomEvent) {
|
|
const target = evt.detail.target;
|
|
if (target && target.id === "modal-container") {
|
|
populateIconGrid();
|
|
Alpine.initTree(target);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showAllIcons(): void {
|
|
populateIconGrid();
|
|
|
|
const searchInput = document.getElementById(
|
|
"icon-search",
|
|
) as HTMLInputElement;
|
|
if (searchInput) {
|
|
searchInput.value = "";
|
|
}
|
|
|
|
const iconGrid = document.getElementById("icon-grid");
|
|
if (!iconGrid) return;
|
|
|
|
const buttons = iconGrid.querySelectorAll(".icon-btn");
|
|
buttons.forEach((btn) => {
|
|
(btn as HTMLElement).style.display = "";
|
|
});
|
|
}
|
|
|
|
const iconData: Record<string, string[]> = {
|
|
"📚": ["book", "books", "library", "read", "reading"],
|
|
"📖": ["book", "open", "read", "reading"],
|
|
"📝": ["memo", "note", "write", "writing", "edit"],
|
|
"📰": ["news", "newspaper", "press", "article"],
|
|
"🗂️": ["card", "index", "organize", "file"],
|
|
"📁": ["file", "folder", "directory"],
|
|
"📂": ["folder", "open", "directory"],
|
|
"📓": ["notebook", "book", "write"],
|
|
"📔": ["notebook", "book"],
|
|
"📕": ["book", "read", "red"],
|
|
"📗": ["book", "read", "green"],
|
|
"📘": ["book", "read", "blue"],
|
|
"📙": ["book", "read", "orange"],
|
|
"⭐": ["star", "favorite", "like", "rating"],
|
|
"❤️": ["heart", "love", "favorite", "like"],
|
|
"🔥": ["fire", "hot", "popular", "trending", "flame"],
|
|
"💡": ["idea", "bulb", "light", "think", "smart"],
|
|
"🎯": ["target", "goal", "aim", "focus"],
|
|
"🏆": ["trophy", "winner", "award", "achievement"],
|
|
"🎨": ["art", "paint", "color", "creative"],
|
|
"✅": ["check", "done", "complete", "success"],
|
|
"❌": ["cross", "x", "wrong", "error", "fail"],
|
|
"⚡️": ["bolt", "fast", "quick", "energy"],
|
|
"🚀": ["rocket", "fast", "launch", "space"],
|
|
"💎": ["gem", "diamond", "stone", "rich"],
|
|
"👍": ["thumb", "up", "good", "yes", "like"],
|
|
"👎": ["thumb", "down", "bad", "no", "dislike"],
|
|
"🏠": ["house", "home", "building"],
|
|
"🏢": ["building", "office", "work", "company"],
|
|
"🚗": ["car", "auto", "vehicle", "drive"],
|
|
"✈️": ["plane", "airplane", "fly", "travel"],
|
|
"🎮": ["game", "play", "video", "gaming"],
|
|
};
|
|
|
|
function populateIconGrid(): void {
|
|
const iconGrid = document.getElementById("icon-grid");
|
|
if (!iconGrid) return;
|
|
iconGrid.innerHTML = "";
|
|
Object.entries(iconData).forEach(([emoji, keywords]) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "icon-btn text-2xl p-1 hover:bg-opacity-80 rounded";
|
|
button.textContent = emoji;
|
|
button.setAttribute("onclick", `selectIcon('${emoji}')`);
|
|
button.setAttribute("data-keywords", keywords.join(","));
|
|
iconGrid.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function selectIcon(icon: string): void {
|
|
const iconInput = document.getElementById(
|
|
"collection-icon",
|
|
) as HTMLInputElement;
|
|
const searchInput = document.getElementById(
|
|
"icon-search",
|
|
) as HTMLInputElement;
|
|
|
|
if (iconInput) iconInput.value = icon;
|
|
if (searchInput) searchInput.value = icon;
|
|
|
|
document.querySelectorAll(".icon-btn").forEach((btn) => {
|
|
(btn as HTMLElement).style.outline = "none";
|
|
(btn as HTMLElement).style.backgroundColor = "";
|
|
});
|
|
|
|
const selectedBtn = document.querySelector(
|
|
`.icon-btn[onclick="selectIcon('${icon}')"]`,
|
|
) as HTMLElement;
|
|
if (selectedBtn) {
|
|
selectedBtn.style.outline = "2px solid var(--text-primary)";
|
|
selectedBtn.style.backgroundColor = "var(--bg-secondary)";
|
|
}
|
|
}
|
|
|
|
function filterIcons(searchTerm: string): void {
|
|
const iconGrid = document.getElementById("icon-grid");
|
|
const iconInput = document.getElementById(
|
|
"collection-icon",
|
|
) as HTMLInputElement;
|
|
if (iconInput) iconInput.value = searchTerm;
|
|
if (!iconGrid) return;
|
|
const buttons = iconGrid.querySelectorAll(".icon-btn");
|
|
const lowerSearchTerm = searchTerm.toLowerCase();
|
|
|
|
buttons.forEach((btn) => {
|
|
const emoji = (btn as HTMLElement).textContent || "";
|
|
const keywords = iconData[emoji] || [];
|
|
|
|
const matches =
|
|
searchTerm === "" ||
|
|
emoji.includes(searchTerm) ||
|
|
keywords.some((keyword) =>
|
|
keyword.toLowerCase().includes(lowerSearchTerm),
|
|
);
|
|
|
|
(btn as HTMLElement).style.display = matches ? "" : "none";
|
|
});
|
|
}
|
|
|
|
function isDetailPage(): boolean {
|
|
const dataEl = document.getElementById("collection-data");
|
|
return !!dataEl?.dataset.id;
|
|
}
|
|
|
|
function initCollectionsPage(): void {
|
|
if (isDetailPage()) {
|
|
const dataEl = document.getElementById("collection-data");
|
|
const collId = dataEl?.dataset.id || "";
|
|
|
|
initLibrarySwitcher({
|
|
onSwitch: async (libraryId) => {
|
|
const dataEl = document.getElementById("collection-data");
|
|
if (dataEl) dataEl.dataset.libraryId = libraryId;
|
|
|
|
await switchWithTransition("books-container", async () => {
|
|
const param = libraryId ? `?library_id=${libraryId}` : "";
|
|
const response = await fetch(
|
|
`/api/collections/${collId}${param}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
);
|
|
if (!response.ok) throw new Error("Failed to load collection");
|
|
const data = await response.json();
|
|
renderCollectionBooks(data.books || []);
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
initializeCollectionWebSocket();
|
|
setupHTMXModalInit();
|
|
}
|
|
|
|
export {
|
|
closeCollectionModal,
|
|
createRule,
|
|
deleteRule,
|
|
filterIcons,
|
|
initColorSelection,
|
|
initializeCollectionWebSocket,
|
|
initCollectionsPage,
|
|
loadCollectionRules,
|
|
loadCollections,
|
|
navigateToCollection,
|
|
populateIconGrid,
|
|
selectColor,
|
|
selectIcon,
|
|
setupHTMXAuth,
|
|
setupHTMXModalInit,
|
|
showAllIcons,
|
|
testRule,
|
|
};
|
|
|
|
Alpine.data("collections", () => ({
|
|
closeCollectionModal,
|
|
createRule,
|
|
deleteRule,
|
|
filterIcons,
|
|
initColorSelection,
|
|
initializeCollectionWebSocket,
|
|
initCollectionsPage,
|
|
loadCollectionRules,
|
|
loadCollections,
|
|
navigateToCollection,
|
|
populateIconGrid,
|
|
selectColor,
|
|
selectIcon,
|
|
setupHTMXModalInit,
|
|
showAllIcons,
|
|
testRule,
|
|
}));
|