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:
+21
-7
@@ -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
@@ -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;
|
|
||||||
|
|||||||
+146
-69
@@ -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)">
|
|
||||||
<span class="text-4xl">📖</span>
|
|
||||||
</div>`
|
|
||||||
}
|
}
|
||||||
<h3 class="font-medium text-sm truncate" style="color: var(--text-primary)">${book.title}</h3>
|
|
||||||
<p class="text-xs truncate" style="color: var(--text-secondary)">${book.author || "Unknown Author"}</p>
|
let html = "";
|
||||||
|
shelves.forEach((shelfBooks) => {
|
||||||
|
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">
|
||||||
|
${shelfBooks.map((book: any) => renderBookCard(book)).join("")}
|
||||||
</div>
|
</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>`;
|
||||||
.join("");
|
});
|
||||||
|
|
||||||
|
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 class="mt-2">
|
||||||
|
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
|
||||||
|
${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
@@ -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
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
+556
-68
@@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast("Device registered! Check your device for sync instructions.", "success");
|
||||||
|
hideAddDeviceModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showToast("Failed to register device", "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to register device", error);
|
||||||
|
showToast("Failed to register device", "error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate device token with confirmation
|
function showDeviceSettings(deviceId: string): void {
|
||||||
function regenerateDeviceToken(deviceId: string, event: Event): void {
|
const modal = document.getElementById("device-settings-modal");
|
||||||
const confirmation =
|
const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
|
||||||
"⚠️ This will revoke current token and generate a new one.\n\n" +
|
const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement;
|
||||||
"The old token will immediately stop working.\n\n" +
|
const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement;
|
||||||
"You will need to update your device configuration with new token.\n\n" +
|
const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement;
|
||||||
"Continue?";
|
const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
|
||||||
|
const syncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement;
|
||||||
|
|
||||||
if (!confirm(confirmation)) {
|
if (!modal || !deviceIdInput) return;
|
||||||
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);
|
||||||
|
});
|
||||||
|
|
||||||
const btn = event.target as HTMLButtonElement;
|
if (modal) modal.classList.remove("hidden");
|
||||||
const originalText = btn.innerHTML;
|
}
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = "🔄 Regenerating...";
|
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
function showShelfMappings(deviceId: string): void {
|
||||||
fetch(`/api/devices/${deviceId}/regenerate-token`, {
|
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",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
})
|
body: JSON.stringify(deviceData),
|
||||||
.then((response: Response) => {
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to regenerate token");
|
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");
|
||||||
}
|
}
|
||||||
return response.json() as Promise<RegenerateTokenResponse>;
|
}
|
||||||
})
|
|
||||||
.then((_data: RegenerateTokenResponse) => {
|
async function handleRevokeDevice(): Promise<void> {
|
||||||
showToast(
|
const token = getToken();
|
||||||
"Token regenerated successfully - update your device config",
|
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
|
||||||
"success",
|
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 || "",
|
||||||
);
|
);
|
||||||
// Reload page to show new token
|
} else if (action === "delete-mapping") {
|
||||||
setTimeout(() => location.reload(), 1500);
|
deleteMapping(button.dataset.mappingId || "");
|
||||||
})
|
|
||||||
.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)
|
export {
|
||||||
Alpine.global("devices", {
|
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
@@ -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
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
+95
-107
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
+21
-14
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user