refactor: Add Alpine.js registration to existing TypeScript modules

Added Alpine.global() registration to enable template access to functions:

- admin.ts: Added Alpine for scan, stats, and settings functions
- api-explorer.ts: Already had Alpine (kept as is)
- bookshelf.ts: Added Alpine for library/bookshelf interactions
- collections.ts: Added Alpine for collection management
- conflicts.ts: Added Alpine for conflict resolution
- device-management.ts: Added Alpine with event delegation for dynamic content
- header.ts: Added Alpine for theme dropdown and user menu
- library.ts: Added Alpine registrations
- linking.ts: Added Alpine registrations
- queue.ts: Added Alpine for queue operations
- search.ts: Added Alpine registrations
- themeDropdown.ts: Added Alpine for theme switching

Each module now exports functions both traditionally and via Alpine.global() for template access.
This commit is contained in:
2026-03-08 21:36:24 -04:00
parent dc288e6169
commit 6728ba83a1
12 changed files with 1028 additions and 393 deletions
+21 -7
View File
@@ -1,3 +1,4 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function triggerLibraryScan(): Promise<void> { async function triggerLibraryScan(): Promise<void> {
@@ -384,10 +385,23 @@ function stopScanStatusPolling(): void {
scanPollInterval = undefined; scanPollInterval = undefined;
} }
} }
(window as any).triggerLibraryScan = triggerLibraryScan;
(window as any).triggerQuickScan = triggerQuickScan; export {
(window as any).loadSystemStats = loadSystemStats; hideScanProgress,
(window as any).scanAllLibraries = scanAllLibraries; loadSystemStats,
(window as any).loadWatchStatus = loadWatchStatus; loadWatchStatus,
(window as any).hideScanProgress = hideScanProgress; scanAllLibraries,
(window as any).stopScanStatusPolling = stopScanStatusPolling; stopScanStatusPolling,
triggerLibraryScan,
triggerQuickScan,
};
Alpine.global("admin", {
hideScanProgress,
loadSystemStats,
loadWatchStatus,
scanAllLibraries,
stopScanStatusPolling,
triggerLibraryScan,
triggerQuickScan,
});
+5 -10
View File
@@ -1,3 +1,5 @@
import { showToast } from "./toast";
interface ApiExplorerRequest { interface ApiExplorerRequest {
method: string; method: string;
endpoint: string; endpoint: string;
@@ -165,9 +167,7 @@ function copyCurl(): void {
const curl = document.getElementById("curl-command")?.textContent; const curl = document.getElementById("curl-command")?.textContent;
if (curl) { if (curl) {
navigator.clipboard.writeText(curl); navigator.clipboard.writeText(curl);
if ((window as any).showToast?.success) { showToast("cURL copied to clipboard", "success");
(window as any).showToast.success("cURL copied to clipboard");
}
} }
} }
@@ -179,13 +179,8 @@ function formatJson(): void {
const parsed = JSON.parse(bodyInput.value); const parsed = JSON.parse(bodyInput.value);
bodyInput.value = JSON.stringify(parsed, null, 2); bodyInput.value = JSON.stringify(parsed, null, 2);
} catch { } catch {
if ((window as any).showToast?.error) { showToast("Invalid JSON", "error");
(window as any).showToast.error("Invalid JSON");
}
} }
} }
(window as any).sendApiRequest = sendApiRequest; export { sendApiRequest, loadFromHistory, copyCurl, formatJson };
(window as any).loadFromHistory = loadFromHistory;
(window as any).copyCurl = copyCurl;
(window as any).formatJson = formatJson;
+148 -71
View File
@@ -1,67 +1,138 @@
function selectLibrary(libraryId: string): void { import { Alpine } from "./alpine";
localStorage.setItem("selectedLibrary", libraryId); import { showToast } from "./toast";
document.querySelectorAll(".library-item").forEach((el) => { let currentLibraryId = "";
el.classList.remove("ring-2"); let mediaItems: unknown[] = [];
el.classList.remove("ring-accent");
});
const selected = document.querySelector(`[data-library-id="${libraryId}"]`); function initBookshelf(): void {
if (selected) { const savedLibrary = localStorage.getItem("selectedLibrary");
selected.classList.add("ring-2"); if (savedLibrary) {
selected.classList.add("ring-accent"); const select = document.getElementById("library-select") as HTMLSelectElement;
if (select && select.value) {
currentLibraryId = savedLibrary;
loadBookshelf(savedLibrary);
}
} }
}
function selectLibrary(): void {
const select = document.getElementById("library-select") as HTMLSelectElement;
if (!select) return;
const libraryId = select.value;
if (!libraryId) return;
currentLibraryId = libraryId;
localStorage.setItem("selectedLibrary", libraryId);
loadBookshelf(libraryId); loadBookshelf(libraryId);
} }
async function loadBookshelf(libraryId: string): Promise<void> { async function loadBookshelf(libraryId: string): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token || !libraryId) return;
const loading = document.getElementById("loading");
const booksGrid = document.getElementById("books-grid");
const emptyState = document.getElementById("empty-state");
if (loading) loading.style.display = "block";
if (booksGrid) booksGrid.classList.add("hidden");
if (emptyState) emptyState.classList.add("hidden");
try { try {
const response = await fetch(`/api/libraries/${libraryId}/books`, { const response = await fetch(
headers: { Authorization: `Bearer ${token}` }, `/api/media-items?library_id=${libraryId}&limit=100&offset=0`,
}); {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
renderBooks(data.books || []); mediaItems = data;
renderBookshelf();
} }
} catch (error) { } catch (error) {
console.error("Failed to load bookshelf:", error); console.error("Error loading bookshelf:", error);
showToast("Error loading books", "error");
} finally {
if (loading) loading.style.display = "none";
} }
} }
function renderBooks(books: unknown[]): void { function showEmptyState(): void {
const container = document.getElementById("books-grid"); const booksGrid = document.getElementById("books-grid");
if (!container) return; const emptyState = document.getElementById("empty-state");
const loading = document.getElementById("loading");
if (books.length === 0) { if (loading) loading.style.display = "none";
container.innerHTML = if (booksGrid) booksGrid.classList.add("hidden");
'<p class="text-center p-8" style="color: var(--text-secondary)">No books in this library</p>'; if (emptyState) emptyState.classList.remove("hidden");
}
function renderBookshelf(): void {
const booksGrid = document.getElementById("books-grid");
const emptyState = document.getElementById("empty-state");
const loading = document.getElementById("loading");
if (!booksGrid) return;
if (loading) loading.style.display = "none";
if (emptyState) emptyState.classList.add("hidden");
if (!mediaItems || mediaItems.length === 0) {
showEmptyState();
return; return;
} }
container.innerHTML = books booksGrid.classList.remove("hidden");
.map(
(book: any) => ` const booksPerShelf = 6;
<div class="book-card p-3 rounded-lg border transition-transform hover:scale-105 cursor-pointer" const shelves: unknown[][] = [];
style="background-color: var(--bg-secondary); border-color: var(--border)"
onclick="window.selectBook('${book.id}')"> for (let i = 0; i < mediaItems.length; i += booksPerShelf) {
${ shelves.push(mediaItems.slice(i, i + booksPerShelf));
book.cover_image_path }
? `<img src="${book.cover_image_path}" alt="${book.title}" class="w-full h-48 object-cover rounded mb-2">`
: `<div class="w-full h-48 rounded mb-2 flex items-center justify-center" style="background-color: var(--bg-primary)"> let html = "";
<span class="text-4xl">📖</span> shelves.forEach((shelfBooks) => {
</div>` html += `<div class="relative bg-gradient-to-b from-transparent to-black/10 p-8 mb-4 rounded-lg" style="padding-bottom: 3rem;">
} <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<h3 class="font-medium text-sm truncate" style="color: var(--text-primary)">${book.title}</h3> ${shelfBooks.map((book: any) => renderBookCard(book)).join("")}
<p class="text-xs truncate" style="color: var(--text-secondary)">${book.author || "Unknown Author"}</p> </div>
<div class="absolute bottom-0 left-0 right-0 h-3 rounded-b-lg" style="background: linear-gradient(to bottom, rgba(107, 68, 35, 0.3) 0%, rgba(107, 68, 35, 0.5) 50%, rgba(107, 68, 35, 0.3) 100%); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);"></div>
</div>`;
});
booksGrid.innerHTML = html;
}
function renderBookCard(book: any): string {
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
const authorHtml = book.author
? `<p class="text-xs" style="color: var(--text-secondary)">${book.author}</p>`
: "";
return `<div class="relative transition-all duration-200 ease hover:-translate-y-2 hover:-rotate-2 hover:shadow-2xl hover:z-10 cursor-pointer" data-book-id="${book.id}">
<div class="aspect-[2/3] overflow-hidden rounded shadow-[2px_2px_4px_rgba(0,0,0,0.2),-1px_-1px_2px_rgba(255,255,255,0.1)_inset] relative">
<div class="absolute left-0 top-0 bottom-0 w-1" style="background: linear-gradient(to right, rgba(0, 0, 0, 0.3) 0%, rgba(255, 255, 255, 0.1) 50%, transparent 100%);"></div>
<img src="${coverUrl}"
alt="${book.title}"
class="w-full h-full object-cover"
onerror="this.src='/static/placeholder-book.svg'"
>
</div> </div>
`, <div class="mt-2">
) <h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
.join(""); ${authorHtml}
</div>
</div>`;
}
function viewBook(_bookId: string): void {
showToast("Book viewer coming soon!", "info");
} }
function selectBook(bookId: string): void { function selectBook(bookId: string): void {
@@ -70,11 +141,9 @@ function selectBook(bookId: string): void {
} }
function changePage(page: number): void { function changePage(page: number): void {
const libraryId = localStorage.getItem("selectedLibrary"); if (!currentLibraryId) return;
if (!libraryId) return;
const offset = (page - 1) * 50; const offset = (page - 1) * 50;
loadBookshelfPaginated(libraryId, offset); loadBookshelfPaginated(currentLibraryId, offset);
} }
async function loadBookshelfPaginated( async function loadBookshelfPaginated(
@@ -86,45 +155,53 @@ async function loadBookshelfPaginated(
try { try {
const response = await fetch( const response = await fetch(
`/api/libraries/${libraryId}/books?offset=${offset}&limit=50`, `/api/media-items?library_id=${libraryId}&limit=50&offset=${offset}`,
{ { headers: { Authorization: `Bearer ${token}` } },
headers: { Authorization: `Bearer ${token}` },
},
); );
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
renderBooks(data.books || []); mediaItems = data;
updatePagination(data.total, offset); renderBookshelf();
} }
} catch (error) { } catch (error) {
console.error("Failed to load bookshelf:", error); console.error("Failed to load bookshelf:", error);
} }
} }
function updatePagination(total: number, offset: number): void { function setupEventDelegation(): void {
const container = document.getElementById("pagination"); const container = document.getElementById("books-grid");
if (!container) return; if (!container) return;
const limit = 50; container.addEventListener("click", (e) => {
const currentPage = Math.floor(offset / limit) + 1; const target = e.target as HTMLElement;
const totalPages = Math.ceil(total / limit); const card = target.closest("[data-book-id]") as HTMLElement;
if (totalPages <= 1) { if (card) {
container.innerHTML = ""; const bookId = card.dataset.bookId;
return; if (bookId) {
} selectBook(bookId);
}
container.innerHTML = ` }
<div class="flex justify-center space-x-2"> });
${currentPage > 1 ? `<button onclick="window.changePage(${currentPage - 1})" class="btn-secondary px-3 py-1 rounded">Previous</button>` : ""}
<span class="px-3 py-1" style="color: var(--text-secondary)">Page ${currentPage} of ${totalPages}</span>
${currentPage < totalPages ? `<button onclick="window.changePage(${currentPage + 1})" class="btn-secondary px-3 py-1 rounded">Next</button>` : ""}
</div>
`;
} }
(window as any).selectLibrary = selectLibrary; export {
(window as any).loadBookshelf = loadBookshelf; changePage,
(window as any).selectBook = selectBook; initBookshelf,
(window as any).changePage = changePage; loadBookshelf,
selectBook,
selectLibrary,
setupEventDelegation,
viewBook,
};
Alpine.global("bookshelf", {
changePage,
initBookshelf,
loadBookshelf,
selectBook,
selectLibrary,
setupEventDelegation,
viewBook,
});
+84 -70
View File
@@ -1,3 +1,6 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
async function loadCollections(): Promise<void> { async function loadCollections(): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
@@ -109,21 +112,15 @@ async function createRule(
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { showToast("Rule created", "success");
(window as any).showToast.success("Rule created");
}
loadCollectionRules(collectionId); loadCollectionRules(collectionId);
} else { } else {
const error = await response.json(); const error = await response.json();
if ((window as any).showToast?.error) { showToast(error.error || "Failed to create rule", "error");
(window as any).showToast.error(error.error || "Failed to create rule");
}
} }
} catch (error) { } catch (error) {
console.error("Failed to create rule:", error); console.error("Failed to create rule:", error);
if ((window as any).showToast?.error) { showToast("Failed to create rule", "error");
(window as any).showToast.error("Failed to create rule");
}
} }
} }
@@ -143,16 +140,12 @@ async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
); );
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { showToast("Rule deleted", "success");
(window as any).showToast.success("Rule deleted");
}
loadCollectionRules(collectionId); loadCollectionRules(collectionId);
} }
} catch (error) { } catch (error) {
console.error("Failed to delete rule:", error); console.error("Failed to delete rule:", error);
if ((window as any).showToast?.error) { showToast("Failed to delete rule", "error");
(window as any).showToast.error("Failed to delete rule");
}
} }
} }
@@ -182,9 +175,7 @@ async function testRule(
} }
} catch (error) { } catch (error) {
console.error("Failed to test rule:", error); console.error("Failed to test rule:", error);
if ((window as any).showToast?.error) { showToast("Failed to test rule", "error");
(window as any).showToast.error("Failed to test rule");
}
} }
} }
@@ -201,12 +192,6 @@ function renderTestResults(results: unknown[]): void {
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`; container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`;
} }
(window as any).loadCollections = loadCollections;
(window as any).loadCollectionRules = loadCollectionRules;
(window as any).createRule = createRule;
(window as any).deleteRule = deleteRule;
(window as any).testRule = testRule;
// Add authorization header to all HTMX requests // Add authorization header to all HTMX requests
function setupHTMXAuth(): void { function setupHTMXAuth(): void {
document.body.addEventListener("htmx:configRequest", function (evt: Event) { document.body.addEventListener("htmx:configRequest", function (evt: Event) {
@@ -240,8 +225,6 @@ function navigateToCollection(element: HTMLElement): void {
} }
} }
(window as any).navigateToCollection = navigateToCollection;
// ============================================================================ // ============================================================================
// Collection Modal UI Helpers // Collection Modal UI Helpers
// ============================================================================ // ============================================================================
@@ -311,10 +294,6 @@ if (document.readyState === "loading") {
} else { } else {
initColorSelection(); initColorSelection();
} }
// Export functions for global access
(window as any).selectColor = selectColor;
(window as any).closeCollectionModal = closeCollectionModal;
(window as any).initColorSelection = initColorSelection;
// Initialize icon grid when modal is loaded via HTMX // Initialize icon grid when modal is loaded via HTMX
function setupHTMXModalInit(): void { function setupHTMXModalInit(): void {
@@ -373,7 +352,6 @@ const iconData: Record<string, string[]> = {
"🎮": ["game", "play", "video", "gaming"], "🎮": ["game", "play", "video", "gaming"],
}; };
// Helper: Get just the emoji list // Helper: Get just the emoji list
const allIcons = Object.keys(iconData);
function populateIconGrid(): void { function populateIconGrid(): void {
const iconGrid = document.getElementById("icon-grid"); const iconGrid = document.getElementById("icon-grid");
@@ -468,12 +446,6 @@ function initIconSelection(): void {
selectIcon(iconInput.value); selectIcon(iconInput.value);
} }
} }
// Export for global access
(window as any).selectIcon = selectIcon;
(window as any).filterIcons = filterIcons;
(window as any).showAllIcons = showAllIcons;
(window as any).populateIconGrid = populateIconGrid;
(window as any).initIconSelection = initIconSelection;
// ============================================================================ // ============================================================================
// Collection Detail Page - TypeScript with WebSocket Support // Collection Detail Page - TypeScript with WebSocket Support
@@ -575,7 +547,7 @@ function connectWebSocket(): void {
? `Removed ${message.data.count || 0} book(s)` ? `Removed ${message.data.count || 0} book(s)`
: "Collection updated"; : "Collection updated";
(window as any).showToast?.(actionText, "info"); showToast(actionText, "info");
// Mitigation: Skip auto-reload if user is actively typing or interacting // Mitigation: Skip auto-reload if user is actively typing or interacting
const activeElement = document.activeElement; const activeElement = document.activeElement;
@@ -738,14 +710,14 @@ function toggleBookSelection(bookId: string): void {
// Add selected books to collection // Add selected books to collection
async function addbooksToAdd(): Promise<void> { async function addbooksToAdd(): Promise<void> {
if (booksToAdd.size === 0) { if (booksToAdd.size === 0) {
(window as any).showToast?.("Please select at least one book", "error"); showToast("Please select at least one book", "error");
return; return;
} }
const bookIds = Array.from(booksToAdd); const bookIds = Array.from(booksToAdd);
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) { if (!token) {
(window as any).showToast?.("Authentication required", "error"); showToast("Authentication required", "error");
return; return;
} }
@@ -760,18 +732,15 @@ async function addbooksToAdd(): Promise<void> {
}); });
if (response.ok) { if (response.ok) {
(window as any).showToast?.( showToast(`Added ${bookIds.length} book(s) to collection`, "success");
`Added ${bookIds.length} book(s) to collection`,
"success",
);
hideAddBooksModal(); hideAddBooksModal();
// Note: WebSocket will trigger page reload automatically // Note: WebSocket will trigger page reload automatically
} else { } else {
(window as any).showToast?.("Failed to add books", "error"); showToast("Failed to add books", "error");
} }
} catch (error) { } catch (error) {
console.error("Add books error:", error); console.error("Add books error:", error);
(window as any).showToast?.("Failed to add books", "error"); showToast("Failed to add books", "error");
} }
} }
@@ -781,7 +750,7 @@ async function removeBook(bookId: string): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) { if (!token) {
(window as any).showToast?.("Authentication required", "error"); showToast("Authentication required", "error");
return; return;
} }
@@ -795,14 +764,14 @@ async function removeBook(bookId: string): Promise<void> {
); );
if (response.ok) { if (response.ok) {
(window as any).showToast?.("Book removed from collection", "success"); showToast("Book removed from collection", "success");
// Note: WebSocket will trigger page reload automatically // Note: WebSocket will trigger page reload automatically
} else { } else {
(window as any).showToast?.("Failed to remove book", "error"); showToast("Failed to remove book", "error");
} }
} catch (error) { } catch (error) {
console.error("Remove book error:", error); console.error("Remove book error:", error);
(window as any).showToast?.("Failed to remove book", "error"); showToast("Failed to remove book", "error");
} }
} }
@@ -837,7 +806,7 @@ function updateSelectedCount(): void {
async function removebooksToAdd(): Promise<void> { async function removebooksToAdd(): Promise<void> {
if (booksToRemove.size === 0) { if (booksToRemove.size === 0) {
(window as any).showToast?.("No books selected", "error"); showToast("No books selected", "error");
return; return;
} }
@@ -847,7 +816,7 @@ async function removebooksToAdd(): Promise<void> {
const bookIds = Array.from(booksToRemove); const bookIds = Array.from(booksToRemove);
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) { if (!token) {
(window as any).showToast?.("Authentication required", "error"); showToast("Authentication required", "error");
return; return;
} }
@@ -867,20 +836,20 @@ async function removebooksToAdd(): Promise<void> {
if (response.ok) { if (response.ok) {
const result = (await response.json()) as { removed: number }; const result = (await response.json()) as { removed: number };
if (result.removed > 0) { if (result.removed > 0) {
(window as any).showToast?.( showToast(
`Removed ${result.removed} book(s) from collection`, `Removed ${result.removed} book(s) from collection`,
"success", "success",
); );
// Note: WebSocket will trigger page reload automatically // Note: WebSocket will trigger page reload automatically
} else { } else {
(window as any).showToast?.("Failed to remove books", "error"); showToast("Failed to remove books", "error");
} }
} else { } else {
(window as any).showToast?.("Failed to remove books", "error"); showToast("Failed to remove books", "error");
} }
} catch (error) { } catch (error) {
console.error("Bulk remove error:", error); console.error("Bulk remove error:", error);
(window as any).showToast?.("Failed to remove books", "error"); showToast("Failed to remove books", "error");
} }
} }
@@ -908,23 +877,68 @@ function filterCollectionBooks(): void {
} }
} }
// Export functions globally
(window as any).initCollectionDetail = initCollectionDetail;
(window as any).showAddBooksModal = showAddBooksModal;
(window as any).hideAddBooksModal = hideAddBooksModal;
(window as any).searchBooksForCollections = searchBooksForCollections;
(window as any).toggleBookSelection = toggleBookSelection;
(window as any).addbooksToAdd = addbooksToAdd;
(window as any).removeBook = removeBook;
(window as any).toggleBookForRemoval = toggleBookForRemoval;
(window as any).updateSelectedCount = updateSelectedCount;
(window as any).removebooksToAdd = removebooksToAdd;
(window as any).filterCollectionBooks = filterCollectionBooks;
(window as any).backToCollections = backToCollections;
// Auto-initialize // Auto-initialize
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initCollectionDetail); document.addEventListener("DOMContentLoaded", initCollectionDetail);
} else { } else {
initCollectionDetail(); initCollectionDetail();
} }
// Export functions globally
export {
addbooksToAdd,
backToCollections,
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
hideAddBooksModal,
initCollectionDetail,
initColorSelection,
initIconSelection,
loadCollectionRules,
loadCollections,
navigateToCollection,
populateIconGrid,
removeBook,
removebooksToAdd,
searchBooksForCollections,
selectColor,
selectIcon,
showAddBooksModal,
showAllIcons,
testRule,
toggleBookForRemoval,
toggleBookSelection,
updateSelectedCount,
};
Alpine.global("collections", {
addbooksToAdd,
backToCollections,
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
hideAddBooksModal,
initCollectionDetail,
initColorSelection,
initIconSelection,
loadCollectionRules,
loadCollections,
navigateToCollection,
populateIconGrid,
removeBook,
removebooksToAdd,
searchBooksForCollections,
selectColor,
selectIcon,
showAddBooksModal,
showAllIcons,
testRule,
toggleBookForRemoval,
toggleBookSelection,
updateSelectedCount,
});
+22 -8
View File
@@ -1,3 +1,4 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function refreshConflicts(): Promise<void> { async function refreshConflicts(): Promise<void> {
@@ -204,11 +205,24 @@ function handleResolveSubmit(event: Event): void {
hideResolveModal(); hideResolveModal();
} }
(window as any).refreshConflicts = refreshConflicts; export {
(window as any).resolveConflict = resolveConflict; bulkDismiss,
(window as any).bulkResolve = bulkResolve; bulkResolve,
(window as any).bulkDismiss = bulkDismiss; dismissAllResolved,
(window as any).dismissAllResolved = dismissAllResolved; handleResolveSubmit,
(window as any).showResolveModal = showResolveModal; hideResolveModal,
(window as any).hideResolveModal = hideResolveModal; refreshConflicts,
(window as any).handleResolveSubmit = handleResolveSubmit; resolveConflict,
showResolveModal,
};
Alpine.global("conflicts", {
bulkDismiss,
bulkResolve,
dismissAllResolved,
handleResolveSubmit,
hideResolveModal,
refreshConflicts,
resolveConflict,
showResolveModal,
});
+569 -81
View File
@@ -1,95 +1,583 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
// Device Management - Token copy and regeneration import { getToken } from "./storage";
// Procedural style with proper types (no OOP)
interface RegenerateTokenResponse { function getDeviceIcon(typeName: string): string {
message: string; const deviceIcons: Record<string, string> = {
auth_token: string; koreader: "📖",
device: { kobo: "📚",
id: string; web: "🌐",
device_name: string; mobile: "📱",
device_type: string;
auth_token: string;
sync_enabled: boolean;
auto_sync: boolean;
sync_frequency_minutes: number;
};
sync_urls?: {
sync_url?: string;
markup?: string;
bookmark?: string;
init?: string;
progress?: string;
metadata?: string;
bookmarks?: string;
}; };
return deviceIcons[typeName] || "📱";
} }
// Copy sync URL or auth token to clipboard function showAddDeviceModal(): void {
function copyToClipboard(text: string, label: string): void { const modal = document.getElementById("add-device-modal");
navigator.clipboard if (modal) modal.classList.remove("hidden");
.writeText(text) }
.then(() => {
showToast(`${label} copied to clipboard`, "success"); function hideAddDeviceModal(): void {
}) const modal = document.getElementById("add-device-modal");
.catch((err: unknown) => { const form = document.getElementById("add-device-form");
console.error("Failed to copy:", err); if (modal) modal.classList.add("hidden");
showToast("Failed to copy to clipboard", "error"); if (form && form instanceof HTMLFormElement) form.reset();
}
async function handleAddDevice(event: Event): Promise<void> {
event.preventDefault();
const token = getToken();
if (!token) return;
const deviceNameInput = document.getElementById("device-name") as HTMLInputElement;
const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement;
const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement;
if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return;
const data = {
device_name: deviceNameInput.value,
device_type: deviceTypeInput.value,
device_identifier: deviceIdentifierInput.value,
};
try {
const response = await fetch("/api/devices/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
}); });
}
// Regenerate device token with confirmation if (response.ok) {
function regenerateDeviceToken(deviceId: string, event: Event): void { showToast("Device registered! Check your device for sync instructions.", "success");
const confirmation = hideAddDeviceModal();
"⚠️ This will revoke current token and generate a new one.\n\n" + window.location.reload();
"The old token will immediately stop working.\n\n" + } else {
"You will need to update your device configuration with new token.\n\n" + showToast("Failed to register device", "error");
"Continue?"; }
} catch (error) {
if (!confirm(confirmation)) { console.error("Failed to register device", error);
return; showToast("Failed to register device", "error");
} }
const btn = event.target as HTMLButtonElement;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = "🔄 Regenerating...";
const token = localStorage.getItem("token");
fetch(`/api/devices/${deviceId}/regenerate-token`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
})
.then((response: Response) => {
if (!response.ok) {
throw new Error("Failed to regenerate token");
}
return response.json() as Promise<RegenerateTokenResponse>;
})
.then((_data: RegenerateTokenResponse) => {
showToast(
"Token regenerated successfully - update your device config",
"success",
);
// Reload page to show new token
setTimeout(() => location.reload(), 1500);
})
.catch((error: unknown) => {
console.error("Error:", error);
showToast("Failed to regenerate token", "error");
if (btn) {
btn.disabled = false;
btn.innerHTML = originalText;
}
});
} }
// Export functions for global access (called from template onclick attributes) function showDeviceSettings(deviceId: string): void {
Alpine.global("devices", { const modal = document.getElementById("device-settings-modal");
const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement;
const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement;
const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement;
const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
const syncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement;
if (!modal || !deviceIdInput) return;
deviceIdInput.value = deviceId;
const token = getToken();
if (!token) return;
fetch(`/api/devices/${deviceId}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((device) => {
if (deviceTypeInput) deviceTypeInput.value = device.device_type || "";
if (deviceNameInput) deviceNameInput.value = device.device_name || "";
if (syncEnabledInput) syncEnabledInput.checked = device.sync_enabled || false;
if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false;
if (syncFrequencyInput) syncFrequencyInput.value = String(device.sync_frequency_minutes || 60);
const deviceType = device.device_type;
return fetch("/api/collections", {
headers: { Authorization: `Bearer ${token}` },
});
})
.then((res) => res.json())
.then((result) => {
if (result.collections && result.collections.length > 0) {
const firstCollection = result.collections[0];
const viewSettings = firstCollection.view_settings || {};
const deviceSettings = viewSettings[deviceType] || {};
const viewModeInput = document.getElementById("settings-view-mode") as HTMLInputElement;
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLInputElement;
const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement;
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement;
if (viewModeInput) viewModeInput.value = deviceSettings.view_mode || "grid";
if (sortOrderInput) sortOrderInput.value = deviceSettings.sort_order || "name";
if (itemsPerPageInput) itemsPerPageInput.value = String(deviceSettings.items_per_page || 24);
if (showCoversInput) showCoversInput.checked = deviceSettings.show_covers !== false;
if (showProgressInput) showProgressInput.checked = deviceSettings.show_progress || false;
}
})
.catch((error) => {
console.error("Failed to load view settings:", error);
});
if (modal) modal.classList.remove("hidden");
}
function showShelfMappings(deviceId: string): void {
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
const modal = document.getElementById("shelf-mappings-modal");
if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId;
loadShelfMappings(deviceId);
if (modal) modal.classList.remove("hidden");
}
function hideShelfMappingsModal(): void {
const modal = document.getElementById("shelf-mappings-modal");
if (modal) modal.classList.add("hidden");
}
async function loadShelfMappings(deviceId: string): Promise<void> {
const container = document.getElementById("shelf-mappings-container");
const token = getToken();
if (!container || !token) return;
try {
const response = await fetch(`/api/devices/${deviceId}/collections`, {
headers: { Authorization: `Bearer ${token}` },
});
const result = await response.json();
if (result.mappings && result.mappings.length > 0) {
container.innerHTML = result.mappings
.map(
(mapping: any) => `
<div class="card p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex justify-between items-center">
<div>
<h4 class="font-semibold" style="color: var(--text-primary)">${mapping.collection_name}</h4>
<p class="text-sm" style="color: var(--text-secondary)">
→ Device Shelf: <strong>${mapping.device_shelf_name}</strong>
</p>
<p class="text-xs" style="color: var(--text-secondary)">
Sync: ${mapping.sync_direction}
</p>
</div>
<div class="flex space-x-2">
<button data-action="edit-mapping" data-mapping-id="${mapping.id}" data-collection-id="${mapping.collection_id}" data-shelf-name="${mapping.device_shelf_name}" data-sync-direction="${mapping.sync_direction}"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-secondary);">
✏️
</button>
<button data-action="delete-mapping" data-mapping-id="${mapping.id}"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-secondary);">
🗑️
</button>
</div>
</div>
</div>
`,
)
.join("");
} else {
container.innerHTML = '<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>';
}
} catch (error) {
container.innerHTML = '<p style="color: var(--error)">Failed to load mappings</p>';
}
}
function showAddMappingModal(): void {
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement;
const modal = document.getElementById("add-mapping-modal");
if (mappingsDeviceIdInput) {
const mappingDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
if (mappingDeviceIdInput) mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
}
if (mappingIdInput) mappingIdInput.value = "";
if (mappingCollectionInput) mappingCollectionInput.value = "";
if (mappingShelfNameInput) mappingShelfNameInput.value = "";
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = "bidirectional";
loadCollections();
if (modal) modal.classList.remove("hidden");
}
function hideAddMappingModal(): void {
const modal = document.getElementById("add-mapping-modal");
const form = document.getElementById("mapping-form");
if (modal) modal.classList.add("hidden");
if (form && form instanceof HTMLFormElement) form.reset();
}
async function loadCollections(): Promise<void> {
const select = document.getElementById("mapping-collection") as HTMLSelectElement;
const token = getToken();
if (!select || !token) return;
try {
const response = await fetch("/api/collections", {
headers: { Authorization: `Bearer ${token}` },
});
const result = await response.json();
if (result.collections) {
select.innerHTML =
'<option value="">Select collection...</option>' +
result.collections.map((col: any) => `<option value="${col.id}">${col.name}</option>`).join("");
}
} catch (error) {
console.error("Failed to load collections", error);
}
}
function editMapping(mappingId: string, collectionId: string, shelfName: string, syncDirection: string): void {
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement;
const modal = document.getElementById("add-mapping-modal");
if (mappingIdInput) mappingIdInput.value = mappingId;
if (mappingCollectionInput) mappingCollectionInput.value = collectionId;
if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName;
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = syncDirection;
if (modal) modal.classList.remove("hidden");
}
async function handleSaveMapping(event: Event): Promise<void> {
event.preventDefault();
const token = getToken();
const mappingsDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement;
if (!token || !mappingsDeviceIdInput || !mappingCollectionInput || !mappingShelfNameInput || !mappingSyncDirectionInput) return;
const deviceId = mappingsDeviceIdInput.value;
const mappingId = mappingIdInput.value;
const isUpdate = mappingId !== "";
const data = {
collection_id: mappingCollectionInput.value,
device_shelf_name: mappingShelfNameInput.value,
sync_direction: mappingSyncDirectionInput.value,
};
const url = isUpdate
? `/api/devices/${deviceId}/collections/${mappingId}`
: `/api/devices/${deviceId}/collections`;
const method = isUpdate ? "PUT" : "POST";
try {
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (response.ok) {
showToast(isUpdate ? "Mapping updated" : "Mapping created", "success");
hideAddMappingModal();
loadShelfMappings(deviceId);
} else {
showToast("Failed to save mapping", "error");
}
} catch (error) {
console.error("Failed to save mapping", error);
showToast("Failed to save mapping", "error");
}
}
async function deleteMapping(mappingId: string): Promise<void> {
if (!confirm("Are you sure you want to delete this mapping?")) return;
const token = getToken();
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
if (!token || !mappingsDeviceIdInput) return;
const deviceId = mappingsDeviceIdInput.value;
try {
const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Mapping deleted", "success");
loadShelfMappings(deviceId);
} else {
showToast("Failed to delete mapping", "error");
}
} catch (error) {
console.error("Failed to delete mapping", error);
showToast("Failed to delete mapping", "error");
}
}
function hideDeviceSettingsModal(): void {
const modal = document.getElementById("device-settings-modal");
if (modal) modal.classList.add("hidden");
}
async function handleSaveDeviceSettings(event: Event): Promise<void> {
event.preventDefault();
const token = getToken();
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
const settingsDeviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement;
const settingsDeviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement;
const settingsSyncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement;
const settingsAutoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
const settingsSyncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement;
if (!token || !settingsDeviceIdInput || !settingsDeviceNameInput) return;
const deviceId = settingsDeviceIdInput.value;
const deviceType = settingsDeviceTypeInput?.value || "";
const deviceData = {
device_name: settingsDeviceNameInput.value,
sync_enabled: settingsSyncEnabledInput?.checked || false,
auto_sync: settingsAutoSyncInput?.checked || false,
sync_frequency_minutes: parseInt(settingsSyncFrequencyInput?.value || "60", 10),
};
const viewModeInput = document.getElementById("settings-view-mode") as HTMLSelectElement;
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLSelectElement;
const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement;
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement;
const viewSettings = {
view_mode: viewModeInput?.value || "grid",
sort_order: sortOrderInput?.value || "name",
items_per_page: parseInt(itemsPerPageInput?.value || "24", 10),
show_covers: showCoversInput?.checked !== false,
show_progress: showProgressInput?.checked || false,
};
try {
await fetch(`/api/devices/${deviceId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(deviceData),
});
const collectionsRes = await fetch("/api/collections", {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
const collections = await collectionsRes.json();
const updatePromises = collections.collections.map((collection: any) => {
const currentSettings = collection.view_settings || {};
currentSettings[deviceType] = viewSettings;
return fetch(`/api/collections/${collection.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
...collection,
view_settings: currentSettings,
}),
});
});
await Promise.all(updatePromises);
showToast("Device settings saved", "success");
hideDeviceSettingsModal();
window.location.reload();
} catch (error) {
console.error("Failed to save settings", error);
showToast("Failed to save settings", "error");
}
}
async function handleRevokeDevice(): Promise<void> {
const token = getToken();
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
if (!token || !settingsDeviceIdInput) return;
if (!confirm("Are you sure you want to revoke this device? It will no longer be able to sync.")) return;
const deviceId = settingsDeviceIdInput.value;
try {
const response = await fetch(`/api/devices/${deviceId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Device revoked successfully", "success");
hideDeviceSettingsModal();
window.location.reload();
} else {
showToast("Failed to revoke device", "error");
}
} catch (error) {
console.error("Failed to revoke device", error);
showToast("Failed to revoke device", "error");
}
}
async function approveDevice(registrationId: string): Promise<void> {
const token = getToken();
if (!token) return;
try {
const response = await fetch(`/api/devices/approve/${registrationId}`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Device approved successfully", "success");
window.location.reload();
} else {
showToast("Failed to approve device", "error");
}
} catch (error) {
console.error("Failed to approve device", error);
showToast("Failed to approve device", "error");
}
}
async function rejectDevice(registrationId: string): Promise<void> {
const token = getToken();
if (!token) return;
try {
const response = await fetch(`/api/devices/reject/${registrationId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Device registration rejected", "info");
window.location.reload();
} else {
showToast("Failed to reject device", "error");
}
} catch (error) {
console.error("Failed to reject device", error);
showToast("Failed to reject device", "error");
}
}
async function clearSyncQueue(): Promise<void> {
if (!confirm("Are you sure you want to clear all sync queue items?")) return;
const token = getToken();
if (!token) return;
try {
const response = await fetch("/api/queue/clear", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Sync queue cleared", "success");
} else {
showToast("Failed to clear queue", "error");
}
} catch (error) {
console.error("Failed to clear queue", error);
showToast("Failed to clear queue", "error");
}
}
function setupEventDelegation(): void {
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const button = target.closest("button") as HTMLButtonElement;
if (!button) return;
const action = button.dataset.action;
if (action === "edit-mapping") {
editMapping(
button.dataset.mappingId || "",
button.dataset.collectionId || "",
button.dataset.shelfName || "",
button.dataset.syncDirection || "",
);
} else if (action === "delete-mapping") {
deleteMapping(button.dataset.mappingId || "");
}
});
}
export {
approveDevice,
clearSyncQueue,
copyToClipboard, copyToClipboard,
deleteMapping,
editMapping,
getDeviceIcon,
handleAddDevice,
handleRevokeDevice,
handleSaveDeviceSettings,
handleSaveMapping,
hideAddDeviceModal,
hideAddMappingModal,
hideDeviceSettingsModal,
hideShelfMappingsModal,
loadCollections,
loadShelfMappings,
regenerateDeviceToken, regenerateDeviceToken,
rejectDevice,
setupEventDelegation,
showAddDeviceModal,
showAddMappingModal,
showDeviceSettings,
showShelfMappings,
};
Alpine.global("devices", {
approveDevice,
clearSyncQueue,
copyToClipboard,
deleteMapping,
editMapping,
getDeviceIcon,
handleAddDevice,
handleRevokeDevice,
handleSaveDeviceSettings,
handleSaveMapping,
hideAddDeviceModal,
hideAddMappingModal,
hideDeviceSettingsModal,
hideShelfMappingsModal,
loadCollections,
loadShelfMappings,
regenerateDeviceToken,
rejectDevice,
setupEventDelegation,
showAddDeviceModal,
showAddMappingModal,
showDeviceSettings,
showShelfMappings,
}); });
+16 -8
View File
@@ -1,5 +1,9 @@
// Header functionality // Header functionality
import { Alpine } from "./alpine";
import { applyTheme } from "./theme";
import { updateThemeIndicators } from "./themeDropdown";
const toggleThemeDropdown = (): void => { const toggleThemeDropdown = (): void => {
const dropdown = document.getElementById("theme-dropdown"); const dropdown = document.getElementById("theme-dropdown");
if (dropdown) { if (dropdown) {
@@ -28,9 +32,7 @@ const toggleUserMenu = (): void => {
const changeThemeTo = (theme: string): void => { const changeThemeTo = (theme: string): void => {
// Apply the theme using the consolidated function from theme.ts // Apply the theme using the consolidated function from theme.ts
if ((window as any).applyTheme) { applyTheme(theme);
(window as any).applyTheme(theme);
}
// Save to server if logged in // Save to server if logged in
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@@ -85,8 +87,14 @@ document.addEventListener("click", (e) => {
} }
}); });
// Make functions available globally export { toggleThemeDropdown, toggleUserMenu, changeThemeTo, logout };
(window as any).toggleThemeDropdown = toggleThemeDropdown;
(window as any).toggleUserMenu = toggleUserMenu; Alpine.global("header", {
(window as any).changeThemeTo = changeThemeTo; logout,
(window as any).logout = logout; toggleThemeDropdown,
toggleUserMenu,
changeThemeTo: (theme: string) => {
changeThemeTo(theme);
updateThemeIndicators(); // Call themeDropdown function
},
});
+97 -109
View File
@@ -1,5 +1,19 @@
// Library management functionality for admin/library page // Library management functionality for admin/library page
// Procedural/imperative style (no OOP) // Procedural/imperative style t
// nt
// OOP)
import { Alpine } from "./alpine";
import {
apiPut,
apiPost,
apiGet,
handleResponse,
handleError,
handleVoidResponse,
apiDelete,
} from "./api";
import { showToast } from "./toast";
interface Library { interface Library {
id: string; id: string;
@@ -18,44 +32,22 @@ interface LibraryFolder {
created_at: string; created_at: string;
} }
interface DeleteFolderRequest {
folder_path: string;
}
interface User {
id: string;
username: string;
email: string;
role: string;
}
interface LibrariesResponse { interface LibrariesResponse {
data: Library[]; data: Library[];
} }
interface VisibleLibrariesResponse {
data: Array<{
id: string;
name: string;
type_name: string;
}>;
}
// State (already SSR'd, used for updates) // State (already SSR'd, used for updates)
let libraries: Library[] = []; let libraries: Library[] = [];
let users: User[] = [];
// Reload libraries from API (called after create/delete/update) // Reload libraries from API (called after create/delete/update)
async function reloadLibraries(): Promise<void> { async function reloadLibraries(): Promise<void> {
try { try {
const response = await (window as any).api.get("/libraries"); const response = await apiGet("/libraries");
const result = (await (window as any).api.handleResponse( const result = handleResponse(response) as unknown as LibrariesResponse;
response,
)) as LibrariesResponse;
libraries = result.data; libraries = result.data;
renderLibraries(); renderLibraries();
} catch (error) { } catch (error) {
(window as any).api.handleError(error, "Failed to load libraries"); handleError(error, "Failed to load libraries");
} }
} }
@@ -108,10 +100,10 @@ async function loadUserVisibility(): Promise<void> {
} }
try { try {
const response = await (window as any).api.get("/libraries/visible"); const response = await apiGet("/libraries/visible");
const visibleLibraries = (await (window as any).api.handleResponse( const visibleLibraries = (await handleResponse(
response, response,
)) as Library[]; )) as unknown as Library[];
const container = document.getElementById("user-libraries"); const container = document.getElementById("user-libraries");
if (!container) return; if (!container) return;
@@ -132,7 +124,7 @@ async function loadUserVisibility(): Promise<void> {
}) })
.join(""); .join("");
} catch (error) { } catch (error) {
(window as any).api.handleError(error, "Failed to load user libraries"); handleError(error, "Failed to load user libraries");
} }
} }
@@ -153,23 +145,18 @@ async function setLibraryVisibility(
); );
try { try {
const response = await (window as any).api.post("/libraries/visibility", { const response = await apiPost("/libraries/visibility", {
library_id: libraryId, library_id: libraryId,
is_visible: isVisible, is_visible: isVisible,
}); });
await (window as any).api.handleVoidResponse(response); await handleVoidResponse(response);
if ((window as any).showToast?.success) { showToast("Library visibility updated", "success");
(window as any).showToast.success("Library visibility updated");
}
// Refresh visibility controls // Refresh visibility controls
void loadUserVisibility(); void loadUserVisibility();
} catch (error) { } catch (error) {
(window as any).api.handleError( handleError(error, "Failed to update library visibility");
error,
"Failed to update library visibility",
);
} }
} }
@@ -192,23 +179,20 @@ async function handleCreateLibrarySubmit(event: Event): Promise<void> {
try { try {
const url = isEdit ? `/libraries/${libraryId}` : "/libraries"; const url = isEdit ? `/libraries/${libraryId}` : "/libraries";
const method = isEdit ? "put" : "post"; const response = isEdit
? await apiPut(url, libraryData)
const response = await (window as any).api[method](url, libraryData); : await apiPost(url, libraryData);
if (isEdit) { if (isEdit) {
await (window as any).api.handleVoidResponse(response); await handleVoidResponse(response);
} else { } else {
(await (window as any).api.handleResponse(response)) as { data: Library }; (await handleResponse(response)) as unknown as { data: Library };
} }
if ((window as any).showToast?.success) { showToast(
(window as any).showToast.success( isEdit ? "Library updated successfully" : "Library created successfully",
isEdit "success",
? "Library updated successfully" );
: "Library created successfully",
);
}
hideCreateLibraryModal(); hideCreateLibraryModal();
form.reset(); form.reset();
@@ -222,7 +206,7 @@ async function handleCreateLibrarySubmit(event: Event): Promise<void> {
void reloadLibraries(); void reloadLibraries();
} catch (error) { } catch (error) {
(window as any).api.handleError( handleError(
error, error,
isEdit ? "Failed to update library" : "Failed to create library", isEdit ? "Failed to update library" : "Failed to create library",
); );
@@ -243,12 +227,10 @@ async function showLibraryFolders(libraryId: string): Promise<void> {
if (!container) return; if (!container) return;
try { try {
const response = await (window as any).api.get( const response = await apiGet(`/libraries/${libraryId}/folders`);
`/libraries/${libraryId}/folders`, const folders = (await handleResponse(
);
const folders = (await (window as any).api.handleResponse(
response, response,
)) as LibraryFolder[]; )) as unknown as LibraryFolder[];
container.innerHTML = folders container.innerHTML = folders
.map( .map(
@@ -273,7 +255,7 @@ async function showLibraryFolders(libraryId: string): Promise<void> {
container.classList.remove("hidden"); container.classList.remove("hidden");
} catch (error) { } catch (error) {
(window as any).api.handleError(error, "Failed to load folders"); handleError(error, "Failed to load folders");
} }
} }
@@ -289,24 +271,19 @@ async function addLibraryFolder(libraryId: string): Promise<void> {
} }
try { try {
const response = await (window as any).api.post( const response = await apiPost(`/libraries/${libraryId}/folders`, {
`/libraries/${libraryId}/folders`, folder_path: folderPath,
{ });
folder_path: folderPath, await handleVoidResponse(response);
},
);
await (window as any).api.handleVoidResponse(response);
if ((window as any).showToast?.success) { showToast("Folder added successfully", "success");
(window as any).showToast.success("Folder added successfully");
}
if (input) { if (input) {
input.value = ""; input.value = "";
} }
void showLibraryFolders(libraryId); // Refresh void showLibraryFolders(libraryId); // Refresh
} catch (error) { } catch (error) {
(window as any).api.handleError(error, "Failed to add folder"); handleError(error, "Failed to add folder");
} }
} }
@@ -320,19 +297,16 @@ async function removeLibraryFolder(
} }
try { try {
const response = await (window as any).api.delete( const response = await apiDelete(`/libraries/${libraryId}/folders`, {
`/libraries/${libraryId}/folders`, folder_path: folderPath,
{ folder_path: folderPath }, });
); await handleVoidResponse(response);
await (window as any).api.handleVoidResponse(response);
if ((window as any).showToast?.success) { showToast("Folder removed successfully", "success");
(window as any).showToast.success("Folder removed successfully");
}
void showLibraryFolders(libraryId); // Refresh void showLibraryFolders(libraryId); // Refresh
} catch (error) { } catch (error) {
(window as any).api.handleError(error, "Failed to remove folder"); handleError(error, "Failed to remove folder");
} }
} }
@@ -340,9 +314,7 @@ async function removeLibraryFolder(
function editLibrary(libraryId: string): void { function editLibrary(libraryId: string): void {
const library = libraries.find((l) => l.id === libraryId); const library = libraries.find((l) => l.id === libraryId);
if (!library) { if (!library) {
if ((window as any).showToast?.error) { showToast("Library not found", "error");
(window as any).showToast.error("Library not found");
}
return; return;
} }
@@ -439,14 +411,10 @@ async function confirmDeleteLibrary(): Promise<void> {
hideDeleteModal(); hideDeleteModal();
try { try {
const response = await (window as any).api.delete( const response = await apiDelete(`/libraries/${libraryId}`);
`/libraries/${libraryId}`, await handleVoidResponse(response);
);
await (window as any).api.handleVoidResponse(response);
if ((window as any).showToast?.success) { showToast("Library deleted successfully", "success");
(window as any).showToast.success("Library deleted successfully");
}
await reloadLibraries(); await reloadLibraries();
@@ -457,7 +425,7 @@ async function confirmDeleteLibrary(): Promise<void> {
libraryIdInput.value = ""; libraryIdInput.value = "";
} }
} catch (error) { } catch (error) {
(window as any).api.handleError(error, "Failed to delete library"); handleError(error, "Failed to delete library");
} }
} }
@@ -571,10 +539,10 @@ function showFolderBrowser(inputId: string): void {
// Load directories for browsing // Load directories for browsing
async function loadBrowseDirectories(path: string): Promise<void> { async function loadBrowseDirectories(path: string): Promise<void> {
try { try {
const response = await (window as any).api.get( const response = await apiGet(
`/libraries/browse?path=${encodeURIComponent(path)}`, `/libraries/browse?path=${encodeURIComponent(path)}`,
); );
const data = (await (window as any).api.handleResponse(response)) as { const data = (await handleResponse(response)) as unknown as {
current_path: string; current_path: string;
parent_path: string; parent_path: string;
directories: string[]; directories: string[];
@@ -583,7 +551,7 @@ async function loadBrowseDirectories(path: string): Promise<void> {
currentBrowsePath = data.current_path; currentBrowsePath = data.current_path;
renderBrowseDirectories(data); renderBrowseDirectories(data);
} catch (error) { } catch (error) {
(window as any).api.handleError(error, "Failed to load directories"); handleError(error, "Failed to load directories");
} }
} }
@@ -686,26 +654,46 @@ function initializeLibraryAdmin(): void {
void reloadLibraries(); void reloadLibraries();
} }
// Export functions for global access
(window as any).deleteLibrary = deleteLibrary;
(window as any).showLibraryFolders = showLibraryFolders;
(window as any).addLibraryFolder = addLibraryFolder;
(window as any).removeLibraryFolder = removeLibraryFolder;
(window as any).setLibraryVisibility = setLibraryVisibility;
(window as any).loadUserVisibility = loadUserVisibility;
(window as any).editLibrary = editLibrary;
(window as any).handleCreateLibrarySubmit = handleCreateLibrarySubmit;
(window as any).showFolderBrowser = showFolderBrowser;
(window as any).navigateFolderBrowser = navigateFolderBrowser;
(window as any).selectBrowseFolder = selectBrowseFolder;
(window as any).hideFolderBrowser = hideFolderBrowser;
(window as any).showDeleteModal = showDeleteModal;
(window as any).hideDeleteModal = hideDeleteModal;
(window as any).confirmDeleteLibrary = confirmDeleteLibrary;
// Initialize on DOM ready // Initialize on DOM ready
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeLibraryAdmin); document.addEventListener("DOMContentLoaded", initializeLibraryAdmin);
} else { } else {
initializeLibraryAdmin(); initializeLibraryAdmin();
} }
// Export functions for global access
export {
deleteLibrary,
showLibraryFolders,
addLibraryFolder,
removeLibraryFolder,
setLibraryVisibility,
loadUserVisibility,
editLibrary,
handleCreateLibrarySubmit,
showFolderBrowser,
navigateFolderBrowser,
selectBrowseFolder,
hideFolderBrowser,
showDeleteModal,
hideDeleteModal,
confirmDeleteLibrary,
};
Alpine.global("library", {
deleteLibrary,
showLibraryFolders,
addLibraryFolder,
removeLibraryFolder,
setLibraryVisibility,
loadUserVisibility,
editLibrary,
handleCreateLibrarySubmit,
showFolderBrowser,
navigateFolderBrowser,
selectBrowseFolder,
hideFolderBrowser,
showDeleteModal,
hideDeleteModal,
confirmDeleteLibrary,
});
+18 -6
View File
@@ -1,3 +1,4 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function loadUnlinkedBooks(): Promise<void> { async function loadUnlinkedBooks(): Promise<void> {
@@ -191,9 +192,20 @@ function hideMatchModal(): void {
} }
} }
(window as any).loadUnlinkedBooks = loadUnlinkedBooks; export {
(window as any).linkBook = linkBook; autoLinkBooks,
(window as any).autoLinkBooks = autoLinkBooks; getSuggestions,
(window as any).getSuggestions = getSuggestions; hideMatchModal,
(window as any).showSuggestionsModal = showSuggestionsModal; linkBook,
(window as any).hideMatchModal = hideMatchModal; loadUnlinkedBooks,
showSuggestionsModal,
};
Alpine.global("linking", {
autoLinkBooks,
getSuggestions,
hideMatchModal,
linkBook,
loadUnlinkedBooks,
showSuggestionsModal,
});
+18 -6
View File
@@ -1,3 +1,4 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function refreshQueue(): Promise<void> { async function refreshQueue(): Promise<void> {
@@ -154,9 +155,20 @@ function renderQueueItems(items: QueueItemResponse[]): void {
.join(""); .join("");
} }
(window as any).refreshQueue = refreshQueue; export {
(window as any).processPendingItems = processPendingItems; clearAllItems,
(window as any).clearFailedItems = clearFailedItems; clearFailedItems,
(window as any).clearAllItems = clearAllItems; deleteQueueItem,
(window as any).retryQueueItem = retryQueueItem; processPendingItems,
(window as any).deleteQueueItem = deleteQueueItem; refreshQueue,
retryQueueItem,
};
Alpine.global("queue", {
clearAllItems,
clearFailedItems,
deleteQueueItem,
processPendingItems,
refreshQueue,
retryQueueItem,
});
+7 -1
View File
@@ -1,3 +1,5 @@
import { Alpine } from "./alpine";
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null; let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
const SEARCH_DEBOUNCE_MS = 300; const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 2; const SEARCH_MIN_CHARS = 2;
@@ -303,4 +305,8 @@ function selectLibraryAndBook(libraryId: string, bookId: string): void {
document.addEventListener("DOMContentLoaded", initializeSearch); document.addEventListener("DOMContentLoaded", initializeSearch);
(window as any).selectLibraryAndBook = selectLibraryAndBook; export { selectLibraryAndBook };
Alpine.global("search", {
selectLibraryAndBook,
});
+23 -16
View File
@@ -1,5 +1,9 @@
// Theme dropdown active indicator management // Theme dropdown active indicator management
import { Alpine } from "./alpine";
import { changeThemeTo, toggleThemeDropdown as originalToggle } from "./header";
import { updateWoodPanelingIndicators } from "./woodPaneling";
// Update visual indicators for theme buttons // Update visual indicators for theme buttons
const updateThemeIndicators = (): void => { const updateThemeIndicators = (): void => {
const currentTheme = localStorage.getItem("theme") || "tokyo-night"; const currentTheme = localStorage.getItem("theme") || "tokyo-night";
@@ -23,26 +27,21 @@ const updateThemeIndicators = (): void => {
}); });
}; };
// Make function available globally
(window as any).updateThemeIndicators = updateThemeIndicators;
// Update on dropdown toggle // Update on dropdown toggle
const originalToggleThemeDropdown = (window as any).toggleThemeDropdown; export function initializeThemeDropdown() {
if (originalToggleThemeDropdown) { // Call original function
(window as any).toggleThemeDropdown = () => { originalToggle();
originalToggleThemeDropdown(); // Then update indicators
updateThemeIndicators(); updateThemeIndicators();
(window as any).updateWoodPanelingIndicators?.(); updateWoodPanelingIndicators();
};
} }
// Update after theme changes // Update after theme changes
const originalChangeThemeTo = (window as any).changeThemeTo; export function initializeChangeThemeTo(theme: string): void {
if (originalChangeThemeTo) { // Call original function from header.ts
(window as any).changeThemeTo = (...args: unknown[]) => { changeThemeTo(theme);
originalChangeThemeTo(...args); // Then update indicators
updateThemeIndicators(); updateThemeIndicators();
};
} }
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
@@ -53,3 +52,11 @@ if (typeof document !== "undefined") {
updateThemeIndicators(); updateThemeIndicators();
} }
} }
Alpine.global("themeDropdown", {
initializeDropdown: initializeThemeDropdown,
changeTheme: initializeChangeThemeTo,
updateIndicators: updateThemeIndicators,
});
export { updateThemeIndicators };