feat(web): update frontend TypeScript modules and API types

This commit updates the web frontend TypeScript modules:

Core modules:
- admin.ts: Admin panel functionality and user management
- analytics.ts: Analytics dashboard and data visualization
- api-explorer.ts: Interactive API documentation explorer
- api.ts: Core API client with request/response handling
- collections.ts: Book collection management UI
- conflicts.ts: Sync conflict resolution interface
- custom-section-builder.ts: Dynamic section builder for UI
- docs.ts: Documentation viewer and navigation
- dom.ts: DOM manipulation utilities and helpers
- header.ts: Application header with navigation
- library.ts: Library view and book grid management
- linking.ts: Device-book linking interface
- password_validation.ts: Client-side password strength validation
- queue.ts: Device sync queue management UI
- search.ts: Full-text search with Lunr integration
- storage.ts: Local storage and cache management
- theme.ts: Theme management and CSS variable updates
- themeDropdown.ts: Theme selector dropdown component
- toast.ts: Toast notification system
- woodPaneling.ts: Visual theme effects
- woodPanelingInit.ts: Visual effects initialization

Type definitions:
- api.d.ts: Updated TypeScript definitions for API responses

These updates enhance the frontend with improved functionality
for book management, device synchronization, and user experience.
This commit is contained in:
2026-02-27 17:06:48 -05:00
parent 4d321528b2
commit ea5ad7a41b
22 changed files with 3133 additions and 2737 deletions
+248 -211
View File
@@ -1,82 +1,84 @@
async function triggerLibraryScan(): Promise<void> { async function triggerLibraryScan(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/libraries/scan', { const response = await fetch("/api/libraries/scan", {
method: 'POST', method: "POST",
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Library scan started'); (window as any).showToast.success("Library scan started");
} }
} else { } else {
const error = await response.json(); const error = await response.json();
if ((window as any).showToast?.error) { if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to start scan'); (window as any).showToast.error(error.error || "Failed to start scan");
} }
}
} catch (error) {
console.error('Scan error:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to start library scan');
}
} }
} catch (error) {
console.error("Scan error:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to start library scan");
}
}
} }
async function triggerQuickScan(): Promise<void> { async function triggerQuickScan(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/libraries/quick-scan', { const response = await fetch("/api/libraries/quick-scan", {
method: 'POST', method: "POST",
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Quick scan started'); (window as any).showToast.success("Quick scan started");
} }
} else { } else {
const error = await response.json(); const error = await response.json();
if ((window as any).showToast?.error) { if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to start quick scan'); (window as any).showToast.error(
} error.error || "Failed to start quick scan",
} );
} catch (error) { }
console.error('Quick scan error:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to start quick scan');
}
} }
} catch (error) {
console.error("Quick scan error:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to start quick scan");
}
}
} }
async function loadSystemStats(): Promise<void> { async function loadSystemStats(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/admin/stats', { const response = await fetch("/api/admin/stats", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const stats = await response.json(); const stats = await response.json();
renderSystemStats(stats); renderSystemStats(stats);
}
} catch (error) {
console.error('Failed to load stats:', error);
} }
} catch (error) {
console.error("Failed to load stats:", error);
}
} }
function renderSystemStats(stats: Record<string, unknown>): void { function renderSystemStats(stats: Record<string, unknown>): void {
const container = document.getElementById('system-stats'); const container = document.getElementById("system-stats");
if (!container) return; if (!container) return;
container.innerHTML = ` container.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)"> <div class="p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books || 0}</p> <p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books || 0}</p>
@@ -99,78 +101,88 @@ function renderSystemStats(stats: Record<string, unknown>): void {
} }
async function scanAllLibraries(): Promise<void> { async function scanAllLibraries(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const libsResp = await fetch('/api/libraries', { const libsResp = await fetch("/api/libraries", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (!libsResp.ok) { if (!libsResp.ok) {
throw new Error('Failed to get libraries'); throw new Error("Failed to get libraries");
}
const libsData = await libsResp.json();
if (!libsData.data || libsData.data.length === 0) {
if ((window as any).showToast?.error) {
(window as any).showToast.error('No libraries found. Please create a library first.');
}
return;
}
const libraries = libsData.data;
const jobs: string[] = [];
const libraryNames: Record<string, string> = {};
for (const lib of libraries) {
const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ force: true })
});
if (scanResp.ok) {
const result = await scanResp.json();
jobs.push(result.job_id);
libraryNames[result.job_id] = lib.name;
} else {
console.error(`Failed to scan library: ${lib.name}`);
}
}
if (jobs.length === 0) {
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to start scan for any library');
}
return;
}
showScanProgress(jobs, libraryNames);
} catch (error) {
console.error('Scan error:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to start scan: ' + (error as Error).message);
}
} }
const libsData = await libsResp.json();
if (!libsData.data || libsData.data.length === 0) {
if ((window as any).showToast?.error) {
(window as any).showToast.error(
"No libraries found. Please create a library first.",
);
}
return;
}
const libraries = libsData.data;
const jobs: string[] = [];
const libraryNames: Record<string, string> = {};
for (const lib of libraries) {
const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ force: true }),
});
if (scanResp.ok) {
const result = await scanResp.json();
jobs.push(result.job_id);
libraryNames[result.job_id] = lib.name;
} else {
console.error(`Failed to scan library: ${lib.name}`);
}
}
if (jobs.length === 0) {
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to start scan for any library");
}
return;
}
showScanProgress(jobs, libraryNames);
} catch (error) {
console.error("Scan error:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error(
"Failed to start scan: " + (error as Error).message,
);
}
}
} }
function showScanProgress(jobIds: string[], libraryNames: Record<string, string>): void { function showScanProgress(
const container = document.getElementById('scan-progress-container') as HTMLElement; jobIds: string[],
const list = document.getElementById('library-progress-list') as HTMLElement; libraryNames: Record<string, string>,
): void {
const container = document.getElementById(
"scan-progress-container",
) as HTMLElement;
const list = document.getElementById("library-progress-list") as HTMLElement;
if (!container || !list) return; if (!container || !list) return;
container.classList.remove('hidden'); container.classList.remove("hidden");
container.classList.remove('opacity-0', '-translate-y-2.5'); container.classList.remove("opacity-0", "-translate-y-2.5");
list.innerHTML = jobIds.map(jobId => ` list.innerHTML = jobIds
.map(
(jobId) => `
<div id="progress-${jobId}" class="p-3 rounded border" <div id="progress-${jobId}" class="p-3 rounded border"
style="background-color: var(--bg-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
@@ -188,133 +200,158 @@ function showScanProgress(jobIds: string[], libraryNames: Record<string, string>
</div> </div>
</div> </div>
</div> </div>
`).join(''); `,
)
.join("");
pollScanProgress(jobIds, libraryNames); pollScanProgress(jobIds, libraryNames);
} }
function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string>): void { function pollScanProgress(
const token = localStorage.getItem('token'); jobIds: string[],
const startTime = Date.now(); _libraryNames: Record<string, string>,
): void {
const token = localStorage.getItem("token");
const startTime = Date.now();
const interval = setInterval(async () => { const interval = setInterval(async () => {
let allComplete = true; let allComplete = true;
let totalProgress = 0; let totalProgress = 0;
let totalFiles = 0; let totalFiles = 0;
let totalNewItems = 0; let totalNewItems = 0;
let totalErrors = 0; let totalErrors = 0;
for (const jobId of jobIds) { for (const jobId of jobIds) {
try { try {
const resp = await fetch(`/api/scanner/status/${jobId}`, { const resp = await fetch(`/api/scanner/status/${jobId}`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (resp.ok) { if (resp.ok) {
const status = await resp.json(); const status = await resp.json();
updateLibraryProgress(jobId, status); updateLibraryProgress(jobId, status);
totalProgress += status.progress || 0; totalProgress += status.progress || 0;
totalFiles += status.files_scanned || 0; totalFiles += status.files_scanned || 0;
totalNewItems += status.new_items || 0; totalNewItems += status.new_items || 0;
totalErrors += status.errors || 0; totalErrors += status.errors || 0;
if (status.status !== 'completed' && status.status !== 'failed') { if (status.status !== "completed" && status.status !== "failed") {
allComplete = false; allComplete = false;
} }
}
} catch (error) {
console.error(`Failed to poll job ${jobId}:`, error);
}
} }
} catch (error) {
console.error(`Failed to poll job ${jobId}:`, error);
}
}
const overallProgress = Math.round(totalProgress / jobIds.length); const overallProgress = Math.round(totalProgress / jobIds.length);
const progressBar = document.getElementById('scan-progress-bar') as HTMLElement; const progressBar = document.getElementById(
const progressText = document.getElementById('scan-progress-text') as HTMLElement; "scan-progress-bar",
const statusText = document.getElementById('scan-status') as HTMLElement; ) as HTMLElement;
const progressText = document.getElementById(
"scan-progress-text",
) as HTMLElement;
const statusText = document.getElementById("scan-status") as HTMLElement;
if (progressBar) progressBar.style.width = overallProgress + '%'; if (progressBar) progressBar.style.width = overallProgress + "%";
if (progressText) progressText.textContent = overallProgress + '%'; if (progressText) progressText.textContent = overallProgress + "%";
const elapsed = Math.round((Date.now() - startTime) / 1000); const elapsed = Math.round((Date.now() - startTime) / 1000);
if (!allComplete && statusText) { if (!allComplete && statusText) {
statusText.textContent = `Scanning... ${elapsed}s elapsed • ${totalFiles} files processed`; statusText.textContent = `Scanning... ${elapsed}s elapsed • ${totalFiles} files processed`;
} }
if (allComplete) { if (allComplete) {
clearInterval(interval); clearInterval(interval);
showScanResults(jobIds.length, totalFiles, totalNewItems, totalErrors, elapsed); showScanResults(
} jobIds.length,
}, 2000); totalFiles,
totalNewItems,
totalErrors,
elapsed,
);
}
}, 2000);
} }
function updateLibraryProgress(jobId: string, status: any): void { function updateLibraryProgress(jobId: string, status: any): void {
const bar = document.getElementById(`bar-${jobId}`) as HTMLElement; const bar = document.getElementById(`bar-${jobId}`) as HTMLElement;
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement; const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
if (bar) { if (bar) {
bar.style.width = (status.progress || 0) + '%'; bar.style.width = (status.progress || 0) + "%";
} }
if (statusText) { if (statusText) {
const statusMessages: Record<string, string> = { const statusMessages: Record<string, string> = {
'pending': 'Pending...', pending: "Pending...",
'running': `Scanning... ${status.progress || 0}%`, running: `Scanning... ${status.progress || 0}%`,
'completed': `✓ Complete (${status.new_items || 0} items)`, completed: `✓ Complete (${status.new_items || 0} items)`,
'failed': `✗ Failed` failed: `✗ Failed`,
}; };
statusText.textContent = statusMessages[status.status] || status.status; statusText.textContent = statusMessages[status.status] || status.status;
} }
} }
function showScanResults(libCount: number, files: number, items: number, errors: number, elapsed: number): void { function showScanResults(
const resultsDiv = document.getElementById('scan-results') as HTMLElement; libCount: number,
const contentDiv = document.getElementById('scan-results-content') as HTMLElement; files: number,
items: number,
errors: number,
elapsed: number,
): void {
const resultsDiv = document.getElementById("scan-results") as HTMLElement;
const contentDiv = document.getElementById(
"scan-results-content",
) as HTMLElement;
if (!resultsDiv || !contentDiv) return; if (!resultsDiv || !contentDiv) return;
contentDiv.innerHTML = ` contentDiv.innerHTML = `
<p>• ${libCount} librar${libCount === 1 ? 'y' : 'ies'} scanned</p> <p>• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned</p>
<p>• ${files} files processed</p> <p>• ${files} files processed</p>
<p>• ${items} new items added</p> <p>• ${items} new items added</p>
${errors > 0 ? `<p style="color: var(--accent);">• ${errors} errors</p>` : ''} ${errors > 0 ? `<p style="color: var(--accent);">• ${errors} errors</p>` : ""}
<p style="color: var(--text-secondary)">Completed in ${elapsed} seconds</p> <p style="color: var(--text-secondary)">Completed in ${elapsed} seconds</p>
`; `;
resultsDiv.classList.remove('hidden'); resultsDiv.classList.remove("hidden");
const statusText = document.getElementById('scan-status') as HTMLElement; const statusText = document.getElementById("scan-status") as HTMLElement;
if (statusText) statusText.textContent = 'Scan complete!'; if (statusText) statusText.textContent = "Scan complete!";
} }
function hideScanProgress(): void { function hideScanProgress(): void {
const container = document.getElementById('scan-progress-container') as HTMLElement; const container = document.getElementById(
if (container) container.classList.add('hidden'); "scan-progress-container",
) as HTMLElement;
if (container) container.classList.add("hidden");
} }
async function loadWatchStatus(): Promise<void> { async function loadWatchStatus(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/scanner/watch/status', { const response = await fetch("/api/scanner/watch/status", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const countEl = document.getElementById('watch-count'); const countEl = document.getElementById("watch-count");
if (countEl) { if (countEl) {
countEl.textContent = data.total_watching?.toString() || '0'; countEl.textContent = data.total_watching?.toString() || "0";
} }
}
} catch (error) {
console.error('Failed to load watch status:', error);
} }
} catch (error) {
console.error("Failed to load watch status:", error);
}
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener("DOMContentLoaded", function () {
loadWatchStatus(); loadWatchStatus();
}); });
(window as any).triggerLibraryScan = triggerLibraryScan; (window as any).triggerLibraryScan = triggerLibraryScan;
+63 -53
View File
@@ -1,47 +1,47 @@
async function loadAnalytics(): Promise<void> { async function loadAnalytics(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const [statsRes, devicesRes, popularRes] = await Promise.all([ const [statsRes, devicesRes, popularRes] = await Promise.all([
fetch('/api/analytics/stats', { fetch("/api/analytics/stats", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}), }),
fetch('/api/analytics/devices', { fetch("/api/analytics/devices", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}), }),
fetch('/api/analytics/popular', { fetch("/api/analytics/popular", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}) }),
]); ]);
if (statsRes.ok) { if (statsRes.ok) {
const stats: ReadingStatsResponse = await statsRes.json(); const stats: ReadingStatsResponse = await statsRes.json();
renderReadingStats(stats); renderReadingStats(stats);
}
if (devicesRes.ok) {
const devices: DeviceUsageResponse = await devicesRes.json();
renderDeviceUsage(devices);
}
if (popularRes.ok) {
const popular: PopularBooksResponse = await popularRes.json();
renderPopularBooks(popular);
}
} catch (error) {
console.error('Failed to load analytics:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to load analytics data');
}
} }
if (devicesRes.ok) {
const devices: DeviceUsageResponse = await devicesRes.json();
renderDeviceUsage(devices);
}
if (popularRes.ok) {
const popular: PopularBooksResponse = await popularRes.json();
renderPopularBooks(popular);
}
} catch (error) {
console.error("Failed to load analytics:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to load analytics data");
}
}
} }
function renderReadingStats(stats: ReadingStatsResponse): void { function renderReadingStats(stats: ReadingStatsResponse): void {
const container = document.getElementById('reading-stats'); const container = document.getElementById("reading-stats");
if (!container) return; if (!container) return;
container.innerHTML = ` container.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)"> <div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books_read}</p> <p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books_read}</p>
@@ -64,15 +64,18 @@ function renderReadingStats(stats: ReadingStatsResponse): void {
} }
function renderDeviceUsage(devices: DeviceUsageResponse): void { function renderDeviceUsage(devices: DeviceUsageResponse): void {
const container = document.getElementById('device-usage'); const container = document.getElementById("device-usage");
if (!container) return; if (!container) return;
if (!devices.devices || devices.devices.length === 0) { if (!devices.devices || devices.devices.length === 0) {
container.innerHTML = '<p style="color: var(--text-secondary)">No device usage data available</p>'; container.innerHTML =
return; '<p style="color: var(--text-secondary)">No device usage data available</p>';
} return;
}
container.innerHTML = devices.devices.map(device => ` container.innerHTML = devices.devices
.map(
(device) => `
<div class="p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
@@ -85,19 +88,24 @@ function renderDeviceUsage(devices: DeviceUsageResponse): void {
</div> </div>
</div> </div>
</div> </div>
`).join(''); `,
)
.join("");
} }
function renderPopularBooks(popular: PopularBooksResponse): void { function renderPopularBooks(popular: PopularBooksResponse): void {
const container = document.getElementById('popular-books'); const container = document.getElementById("popular-books");
if (!container) return; if (!container) return;
if (!popular.books || popular.books.length === 0) { if (!popular.books || popular.books.length === 0) {
container.innerHTML = '<p style="color: var(--text-secondary)">No reading history available</p>'; container.innerHTML =
return; '<p style="color: var(--text-secondary)">No reading history available</p>';
} return;
}
container.innerHTML = popular.books.map(book => ` container.innerHTML = popular.books
.map(
(book) => `
<div class="p-3 rounded-lg border flex items-center space-x-3" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="p-3 rounded-lg border flex items-center space-x-3" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex-1"> <div class="flex-1">
<p class="font-medium" style="color: var(--text-primary)">${book.title}</p> <p class="font-medium" style="color: var(--text-primary)">${book.title}</p>
@@ -108,9 +116,11 @@ function renderPopularBooks(popular: PopularBooksResponse): void {
<p class="text-sm" style="color: var(--text-secondary)">${Math.round(book.avg_completion * 100)}%</p> <p class="text-sm" style="color: var(--text-secondary)">${Math.round(book.avg_completion * 100)}%</p>
</div> </div>
</div> </div>
`).join(''); `,
)
.join("");
} }
document.addEventListener('DOMContentLoaded', loadAnalytics); document.addEventListener("DOMContentLoaded", loadAnalytics);
(window as any).loadAnalytics = loadAnalytics; (window as any).loadAnalytics = loadAnalytics;
+116 -98
View File
@@ -1,70 +1,78 @@
interface ApiExplorerRequest { interface ApiExplorerRequest {
method: string; method: string;
endpoint: string; endpoint: string;
headers: Record<string, string>; headers: Record<string, string>;
body?: string; body?: string;
} }
const requestHistory: ApiExplorerRequest[] = []; const requestHistory: ApiExplorerRequest[] = [];
function sendApiRequest(): void { function sendApiRequest(): void {
const method = (document.getElementById('api-method') as HTMLSelectElement)?.value || 'GET'; const method =
const endpoint = (document.getElementById('api-endpoint') as HTMLInputElement)?.value || ''; (document.getElementById("api-method") as HTMLSelectElement)?.value ||
const bodyText = (document.getElementById('api-body') as HTMLTextAreaElement)?.value || ''; "GET";
const endpoint =
(document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
const bodyText =
(document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json' "Content-Type": "application/json",
}; };
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const request: ApiExplorerRequest = { const request: ApiExplorerRequest = {
method, method,
endpoint, endpoint,
headers, headers,
body: bodyText || undefined body: bodyText || undefined,
}; };
addToHistory(request); addToHistory(request);
const startTime = performance.now(); const startTime = performance.now();
fetch(endpoint, { fetch(endpoint, {
method, method,
headers, headers,
body: bodyText || undefined body: bodyText || undefined,
})
.then(async (response) => {
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
const responseText = await response.text();
let responseData: unknown;
try {
responseData = JSON.parse(responseText);
} catch {
responseData = responseText;
}
displayResponse(response, responseData, duration);
generateCurl(request);
}) })
.then(async response => { .catch((error) => {
const endTime = performance.now(); displayError(error);
const duration = Math.round(endTime - startTime);
const responseText = await response.text();
let responseData: unknown;
try {
responseData = JSON.parse(responseText);
} catch {
responseData = responseText;
}
displayResponse(response, responseData, duration);
generateCurl(request);
})
.catch(error => {
displayError(error);
}); });
} }
function displayResponse(response: Response, data: unknown, duration: number): void { function displayResponse(
const container = document.getElementById('api-response'); response: Response,
if (!container) return; data: unknown,
duration: number,
): void {
const container = document.getElementById("api-response");
if (!container) return;
const statusColor = response.ok ? 'var(--accent)' : 'var(--error)'; const statusColor = response.ok ? "var(--accent)" : "var(--error)";
container.innerHTML = ` container.innerHTML = `
<div class="mb-4"> <div class="mb-4">
<span class="px-2 py-1 rounded text-sm" style="background-color: ${statusColor}; color: var(--bg-primary)"> <span class="px-2 py-1 rounded text-sm" style="background-color: ${statusColor}; color: var(--bg-primary)">
${response.status} ${response.statusText} ${response.status} ${response.statusText}
@@ -76,10 +84,10 @@ function displayResponse(response: Response, data: unknown, duration: number): v
} }
function displayError(error: Error): void { function displayError(error: Error): void {
const container = document.getElementById('api-response'); const container = document.getElementById("api-response");
if (!container) return; if (!container) return;
container.innerHTML = ` container.innerHTML = `
<div class="p-4 rounded" style="background-color: var(--bg-primary)"> <div class="p-4 rounded" style="background-color: var(--bg-primary)">
<p style="color: var(--error)">Error: ${error.message}</p> <p style="color: var(--error)">Error: ${error.message}</p>
</div> </div>
@@ -87,84 +95,94 @@ function displayError(error: Error): void {
} }
function generateCurl(request: ApiExplorerRequest): void { function generateCurl(request: ApiExplorerRequest): void {
const container = document.getElementById('curl-command'); const container = document.getElementById("curl-command");
if (!container) return; if (!container) return;
let curl = `curl -X ${request.method} '${request.endpoint}'`; let curl = `curl -X ${request.method} '${request.endpoint}'`;
Object.entries(request.headers).forEach(([key, value]) => { Object.entries(request.headers).forEach(([key, value]) => {
curl += ` \\\n -H '${key}: ${value}'`; curl += ` \\\n -H '${key}: ${value}'`;
}); });
if (request.body) { if (request.body) {
curl += ` \\\n -d '${request.body}'`; curl += ` \\\n -d '${request.body}'`;
} }
container.textContent = curl; container.textContent = curl;
} }
function addToHistory(request: ApiExplorerRequest): void { function addToHistory(request: ApiExplorerRequest): void {
requestHistory.unshift(request); requestHistory.unshift(request);
if (requestHistory.length > 20) { if (requestHistory.length > 20) {
requestHistory.pop(); requestHistory.pop();
} }
renderHistory(); renderHistory();
} }
function renderHistory(): void { function renderHistory(): void {
const container = document.getElementById('request-history'); const container = document.getElementById("request-history");
if (!container) return; if (!container) return;
if (requestHistory.length === 0) { if (requestHistory.length === 0) {
container.innerHTML = '<p class="text-sm p-2" style="color: var(--text-secondary)">No requests yet</p>'; container.innerHTML =
return; '<p class="text-sm p-2" style="color: var(--text-secondary)">No requests yet</p>';
} return;
}
container.innerHTML = requestHistory.slice(0, 10).map((req, i) => ` container.innerHTML = requestHistory
.slice(0, 10)
.map(
(req, i) => `
<div class="p-2 rounded cursor-pointer hover:bg-opacity-50 transition-colors" <div class="p-2 rounded cursor-pointer hover:bg-opacity-50 transition-colors"
style="background-color: var(--bg-secondary)" style="background-color: var(--bg-secondary)"
onclick="window.loadFromHistory(${i})"> onclick="window.loadFromHistory(${i})">
<span class="text-xs font-mono" style="color: ${req.method === 'GET' ? 'var(--accent)' : req.method === 'POST' ? 'var(--success)' : req.method === 'DELETE' ? 'var(--error)' : 'var(--text-primary)'}">${req.method}</span> <span class="text-xs font-mono" style="color: ${req.method === "GET" ? "var(--accent)" : req.method === "POST" ? "var(--success)" : req.method === "DELETE" ? "var(--error)" : "var(--text-primary)"}">${req.method}</span>
<span class="text-xs ml-2" style="color: var(--text-secondary)">${req.endpoint}</span> <span class="text-xs ml-2" style="color: var(--text-secondary)">${req.endpoint}</span>
</div> </div>
`).join(''); `,
)
.join("");
} }
function loadFromHistory(index: number): void { function loadFromHistory(index: number): void {
const request = requestHistory[index]; const request = requestHistory[index];
if (!request) return; if (!request) return;
const methodSelect = document.getElementById('api-method') as HTMLSelectElement; const methodSelect = document.getElementById(
const endpointInput = document.getElementById('api-endpoint') as HTMLInputElement; "api-method",
const bodyInput = document.getElementById('api-body') as HTMLTextAreaElement; ) as HTMLSelectElement;
const endpointInput = document.getElementById(
"api-endpoint",
) as HTMLInputElement;
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
if (methodSelect) methodSelect.value = request.method; if (methodSelect) methodSelect.value = request.method;
if (endpointInput) endpointInput.value = request.endpoint; if (endpointInput) endpointInput.value = request.endpoint;
if (bodyInput) bodyInput.value = request.body || ''; if (bodyInput) bodyInput.value = request.body || "";
} }
function copyCurl(): void { 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) { if ((window as any).showToast?.success) {
(window as any).showToast.success('cURL copied to clipboard'); (window as any).showToast.success("cURL copied to clipboard");
}
} }
}
} }
function formatJson(): void { function formatJson(): void {
const bodyInput = document.getElementById('api-body') as HTMLTextAreaElement; const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
if (!bodyInput) return; if (!bodyInput) return;
try { try {
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) { if ((window as any).showToast?.error) {
(window as any).showToast.error('Invalid JSON'); (window as any).showToast.error("Invalid JSON");
}
} }
}
} }
(window as any).sendApiRequest = sendApiRequest; (window as any).sendApiRequest = sendApiRequest;
+71 -63
View File
@@ -1,91 +1,99 @@
function getAuthHeader(): string { function getAuthHeader(): string {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
return token ? `Bearer ${token}` : ''; return token ? `Bearer ${token}` : "";
} }
async function apiGet(url: string): Promise<Response> { async function apiGet(url: string): Promise<Response> {
return fetch(`/api${url}`, { return fetch(`/api${url}`, {
headers: { headers: {
'Authorization': getAuthHeader(), Authorization: getAuthHeader(),
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}); });
} }
async function apiPost(url: string, data?: unknown): Promise<Response> { async function apiPost(url: string, data?: unknown): Promise<Response> {
return fetch(`/api${url}`, { return fetch(`/api${url}`, {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': getAuthHeader(), Authorization: getAuthHeader(),
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: data ? JSON.stringify(data) : undefined body: data ? JSON.stringify(data) : undefined,
}); });
} }
async function apiPut(url: string, data?: unknown): Promise<Response> { async function apiPut(url: string, data?: unknown): Promise<Response> {
return fetch(`/api${url}`, { return fetch(`/api${url}`, {
method: 'PUT', method: "PUT",
headers: { headers: {
'Authorization': getAuthHeader(), Authorization: getAuthHeader(),
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: data ? JSON.stringify(data) : undefined body: data ? JSON.stringify(data) : undefined,
}); });
} }
async function apiDelete<T extends object>(url: string, data?: T): Promise<Response> { async function apiDelete<T extends object>(
return fetch(`/api${url}`, { url: string,
method: 'DELETE', data?: T,
headers: { ): Promise<Response> {
'Authorization': getAuthHeader(), return fetch(`/api${url}`, {
'Content-Type': 'application/json' method: "DELETE",
}, headers: {
body: data ? JSON.stringify(data) : undefined Authorization: getAuthHeader(),
}); "Content-Type": "application/json",
},
body: data ? JSON.stringify(data) : undefined,
});
} }
async function apiPatch(url: string, data?: unknown): Promise<Response> { async function apiPatch(url: string, data?: unknown): Promise<Response> {
return fetch(`/api${url}`, { return fetch(`/api${url}`, {
method: 'PATCH', method: "PATCH",
headers: { headers: {
'Authorization': getAuthHeader(), Authorization: getAuthHeader(),
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: data ? JSON.stringify(data) : undefined body: data ? JSON.stringify(data) : undefined,
}); });
} }
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); const errorData = await response
throw new Error(errorData.error || `HTTP ${response.status}`); .json()
} .catch(() => ({ error: "Unknown error" }));
return response.json(); throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json();
} }
async function handleVoidResponse(response: Response): Promise<void> { async function handleVoidResponse(response: Response): Promise<void> {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); const errorData = await response
throw new Error(errorData.error || `HTTP ${response.status}`); .json()
} .catch(() => ({ error: "Unknown error" }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
} }
function handleError(error: unknown, context: string): void { function handleError(error: unknown, context: string): void {
console.error(`${context}:`, error); console.error(`${context}:`, error);
const message = error instanceof Error ? error.message : 'An unexpected error occurred'; const message =
if ((window as any).showToast?.error) { error instanceof Error ? error.message : "An unexpected error occurred";
(window as any).showToast.error(message); if ((window as any).showToast?.error) {
} (window as any).showToast.error(message);
}
} }
(window as any).api = { (window as any).api = {
get: apiGet, get: apiGet,
post: apiPost, post: apiPost,
put: apiPut, put: apiPut,
delete: apiDelete, delete: apiDelete,
patch: apiPatch, patch: apiPatch,
handleResponse, handleResponse,
handleVoidResponse, handleVoidResponse,
handleError handleError,
}; };
+141 -118
View File
@@ -1,181 +1,204 @@
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;
try { try {
const response = await fetch('/api/collections', { const response = await fetch("/api/collections", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
renderCollections(data.collections || []); renderCollections(data.collections || []);
}
} catch (error) {
console.error('Failed to load collections:', error);
} }
} catch (error) {
console.error("Failed to load collections:", error);
}
} }
function renderCollections(collections: CollectionData[]): void { function renderCollections(collections: CollectionData[]): void {
const container = document.getElementById('collections-list'); const container = document.getElementById("collections-list");
if (!container) return; if (!container) return;
if (collections.length === 0) { if (collections.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No collections yet</p>'; container.innerHTML =
return; '<p class="text-center p-4" style="color: var(--text-secondary)">No collections yet</p>';
} return;
}
container.innerHTML = collections.map(collection => ` container.innerHTML = collections
.map(
(collection) => `
<a href="/collections/${collection.id}" class="block p-4 rounded-lg border transition-colors hover:border-opacity-50" <a href="/collections/${collection.id}" class="block p-4 rounded-lg border transition-colors hover:border-opacity-50"
style="background-color: var(--bg-secondary); border-color: var(--border)"> style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<span class="text-2xl">${collection.icon || '📁'}</span> <span class="text-2xl">${collection.icon || "📁"}</span>
<div> <div>
<h3 class="font-medium" style="color: var(--text-primary)">${collection.name}</h3> <h3 class="font-medium" style="color: var(--text-primary)">${collection.name}</h3>
${collection.description ? `<p class="text-sm" style="color: var(--text-secondary)">${collection.description}</p>` : ''} ${collection.description ? `<p class="text-sm" style="color: var(--text-secondary)">${collection.description}</p>` : ""}
</div> </div>
</div> </div>
</a> </a>
`).join(''); `,
)
.join("");
} }
async function loadCollectionRules(collectionId: string): Promise<void> { async function loadCollectionRules(collectionId: string): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch(`/api/collections/${collectionId}/rules`, { const response = await fetch(`/api/collections/${collectionId}/rules`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const rules: CollectionRule[] = await response.json(); const rules: CollectionRule[] = await response.json();
renderRules(rules); renderRules(rules);
}
} catch (error) {
console.error('Failed to load rules:', error);
} }
} catch (error) {
console.error("Failed to load rules:", error);
}
} }
function renderRules(rules: CollectionRule[]): void { function renderRules(rules: CollectionRule[]): void {
const container = document.getElementById('rules-list'); const container = document.getElementById("rules-list");
if (!container) return; if (!container) return;
if (rules.length === 0) { if (rules.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>'; container.innerHTML =
return; '<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
} return;
}
container.innerHTML = rules.map(rule => ` container.innerHTML = rules
.map(
(rule) => `
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center" <div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
style="background-color: var(--bg-secondary); border-color: var(--border)"> style="background-color: var(--bg-secondary); border-color: var(--border)">
<div> <div>
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p> <p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? 'Enabled' : 'Disabled'}</p> <p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}</p>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button> <button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button> <button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
</div> </div>
</div> </div>
`).join(''); `,
)
.join("");
} }
async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> { async function createRule(
const token = localStorage.getItem('token'); collectionId: string,
if (!token) return; rule: Partial<CollectionRule>,
): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const response = await fetch(`/api/collections/${collectionId}/rules`, { const response = await fetch(`/api/collections/${collectionId}/rules`, {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify(rule) body: JSON.stringify(rule),
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Rule created'); (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) { if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to create rule'); (window as any).showToast.error(error.error || "Failed to create rule");
} }
}
} catch (error) {
console.error('Failed to create rule:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to create rule');
}
} }
} catch (error) {
console.error("Failed to create rule:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to create rule");
}
}
} }
async function deleteRule(collectionId: string, ruleId: string): Promise<void> { async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
if (!confirm('Are you sure you want to delete this rule?')) return; if (!confirm("Are you sure you want to delete this rule?")) return;
try { try {
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, { const response = await fetch(
method: 'DELETE', `/api/collections/${collectionId}/rules/${ruleId}`,
headers: { 'Authorization': `Bearer ${token}` } {
}); method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Rule deleted'); (window as any).showToast.success("Rule deleted");
} }
loadCollectionRules(collectionId); loadCollectionRules(collectionId);
}
} catch (error) {
console.error('Failed to delete rule:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to delete rule');
}
} }
} catch (error) {
console.error("Failed to delete rule:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to delete rule");
}
}
} }
async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> { async function testRule(
const token = localStorage.getItem('token'); collectionId: string,
if (!token) return; rule: Partial<CollectionRule>,
): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const response = await fetch(`/api/collections/${collectionId}/rules/test`, { const response = await fetch(
method: 'POST', `/api/collections/${collectionId}/rules/test`,
headers: { {
'Authorization': `Bearer ${token}`, method: "POST",
'Content-Type': 'application/json' headers: {
}, Authorization: `Bearer ${token}`,
body: JSON.stringify(rule) "Content-Type": "application/json",
}); },
body: JSON.stringify(rule),
},
);
if (response.ok) { if (response.ok) {
const results = await response.json(); const results = await response.json();
renderTestResults(results); renderTestResults(results);
}
} catch (error) {
console.error('Failed to test rule:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to test rule');
}
} }
} catch (error) {
console.error("Failed to test rule:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to test rule");
}
}
} }
function renderTestResults(results: unknown[]): void { function renderTestResults(results: unknown[]): void {
const container = document.getElementById('test-results'); const container = document.getElementById("test-results");
if (!container) return; if (!container) return;
if (!results || (Array.isArray(results) && results.length === 0)) { if (!results || (Array.isArray(results) && results.length === 0)) {
container.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>'; container.innerHTML =
return; '<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
} return;
}
container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`; 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).loadCollections = loadCollections;
+164 -140
View File
@@ -1,203 +1,227 @@
async function refreshConflicts(): Promise<void> { async function refreshConflicts(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/conflicts', { const response = await fetch("/api/conflicts", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const data: ConflictListResponse = await response.json(); const data: ConflictListResponse = await response.json();
renderConflicts(data.conflicts); renderConflicts(data.conflicts);
updateConflictStats(data); updateConflictStats(data);
}
} catch (error) {
console.error('Failed to refresh conflicts:', error);
} }
} catch (error) {
console.error("Failed to refresh conflicts:", error);
}
} }
async function resolveConflict(conflictId: string, winner: string, manualData?: Record<string, unknown>): Promise<void> { async function resolveConflict(
const token = localStorage.getItem('token'); conflictId: string,
if (!token) return; winner: string,
manualData?: Record<string, unknown>,
): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const response = await fetch(`/api/conflicts/${conflictId}/resolve`, { const response = await fetch(`/api/conflicts/${conflictId}/resolve`, {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ winner, manual_data: manualData }) body: JSON.stringify({ winner, manual_data: manualData }),
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Conflict resolved'); (window as any).showToast.success("Conflict resolved");
} }
refreshConflicts(); refreshConflicts();
} else { } else {
const error = await response.json(); const error = await response.json();
if ((window as any).showToast?.error) { if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to resolve conflict'); (window as any).showToast.error(
} error.error || "Failed to resolve conflict",
} );
} catch (error) { }
console.error('Failed to resolve conflict:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to resolve conflict');
}
} }
} catch (error) {
console.error("Failed to resolve conflict:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to resolve conflict");
}
}
} }
async function bulkResolve(strategy: 'most_recent' | 'highest_progress', conflictIds: string[]): Promise<void> { async function bulkResolve(
const token = localStorage.getItem('token'); strategy: "most_recent" | "highest_progress",
if (!token) return; conflictIds: string[],
): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const response = await fetch('/api/conflicts/bulk-resolve', { const response = await fetch("/api/conflicts/bulk-resolve", {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ conflict_ids: conflictIds, strategy }) body: JSON.stringify({ conflict_ids: conflictIds, strategy }),
}); });
if (response.ok) { if (response.ok) {
const data: BulkResolveResponse = await response.json(); const data: BulkResolveResponse = await response.json();
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success(`Resolved ${data.success} conflicts`); (window as any).showToast.success(`Resolved ${data.success} conflicts`);
} }
refreshConflicts(); refreshConflicts();
}
} catch (error) {
console.error('Failed to bulk resolve:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to bulk resolve conflicts');
}
} }
} catch (error) {
console.error("Failed to bulk resolve:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to bulk resolve conflicts");
}
}
} }
async function bulkDismiss(conflictIds: string[]): Promise<void> { async function bulkDismiss(conflictIds: string[]): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/conflicts/bulk-dismiss', { const response = await fetch("/api/conflicts/bulk-dismiss", {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ conflict_ids: conflictIds }) body: JSON.stringify({ conflict_ids: conflictIds }),
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Conflicts dismissed'); (window as any).showToast.success("Conflicts dismissed");
} }
refreshConflicts(); refreshConflicts();
}
} catch (error) {
console.error('Failed to dismiss conflicts:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to dismiss conflicts');
}
} }
} catch (error) {
console.error("Failed to dismiss conflicts:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to dismiss conflicts");
}
}
} }
async function dismissAllResolved(): Promise<void> { async function dismissAllResolved(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/conflicts/dismiss-resolved', { const response = await fetch("/api/conflicts/dismiss-resolved", {
method: 'POST', method: "POST",
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Resolved conflicts dismissed'); (window as any).showToast.success("Resolved conflicts dismissed");
} }
refreshConflicts(); refreshConflicts();
}
} catch (error) {
console.error('Failed to dismiss resolved:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to dismiss resolved conflicts');
}
} }
} catch (error) {
console.error("Failed to dismiss resolved:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to dismiss resolved conflicts");
}
}
} }
function renderConflicts(conflicts: ConflictDetailResponse[]): void { function renderConflicts(conflicts: ConflictDetailResponse[]): void {
const container = document.getElementById('conflicts-list'); const container = document.getElementById("conflicts-list");
if (!container) return; if (!container) return;
if (conflicts.length === 0) { if (conflicts.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>'; container.innerHTML =
return; '<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
} return;
}
container.innerHTML = conflicts.map(conflict => ` container.innerHTML = conflicts
.map(
(conflict) => `
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3> <h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p> <p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
</div> </div>
${conflict.resolution_status === 'unresolved' ? ` ${
conflict.resolution_status === "unresolved"
? `
<div class="flex space-x-2"> <div class="flex space-x-2">
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button> <button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
</div> </div>
` : ''} `
: ""
}
</div> </div>
</div> </div>
`).join(''); `,
)
.join("");
} }
function updateConflictStats(data: ConflictListResponse): void { function updateConflictStats(data: ConflictListResponse): void {
const totalEl = document.getElementById('conflicts-total'); const totalEl = document.getElementById("conflicts-total");
const unresolvedEl = document.getElementById('conflicts-unresolved'); const unresolvedEl = document.getElementById("conflicts-unresolved");
if (totalEl) totalEl.textContent = String(data.total); if (totalEl) totalEl.textContent = String(data.total);
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved); if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
} }
function showResolveModal(conflictId: string): void { function showResolveModal(conflictId: string): void {
const modal = document.getElementById('resolve-modal'); const modal = document.getElementById("resolve-modal");
const conflictIdInput = document.getElementById('resolve-conflict-id') as HTMLInputElement; const conflictIdInput = document.getElementById(
"resolve-conflict-id",
) as HTMLInputElement;
if (modal && conflictIdInput) { if (modal && conflictIdInput) {
conflictIdInput.value = conflictId; conflictIdInput.value = conflictId;
modal.classList.remove('hidden'); modal.classList.remove("hidden");
} }
} }
function hideResolveModal(): void { function hideResolveModal(): void {
const modal = document.getElementById('resolve-modal'); const modal = document.getElementById("resolve-modal");
if (modal) { if (modal) {
modal.classList.add('hidden'); modal.classList.add("hidden");
} }
} }
function handleResolveSubmit(event: Event): void { function handleResolveSubmit(event: Event): void {
event.preventDefault(); event.preventDefault();
const form = event.target as HTMLFormElement; const form = event.target as HTMLFormElement;
const conflictId = (form.querySelector('#resolve-conflict-id') as HTMLInputElement)?.value; const conflictId = (
const winner = (form.querySelector('input[name="winner"]:checked') as HTMLInputElement)?.value; form.querySelector("#resolve-conflict-id") as HTMLInputElement
)?.value;
const winner = (
form.querySelector('input[name="winner"]:checked') as HTMLInputElement
)?.value;
if (!conflictId || !winner) { if (!conflictId || !winner) {
if ((window as any).showToast?.error) { if ((window as any).showToast?.error) {
(window as any).showToast.error('Please select a winner'); (window as any).showToast.error("Please select a winner");
}
return;
} }
return;
}
resolveConflict(conflictId, winner); resolveConflict(conflictId, winner);
hideResolveModal(); hideResolveModal();
} }
(window as any).refreshConflicts = refreshConflicts; (window as any).refreshConflicts = refreshConflicts;
File diff suppressed because it is too large Load Diff
+65 -57
View File
@@ -1,86 +1,94 @@
function toggleSidebar(): void { function toggleSidebar(): void {
const sidebar = document.getElementById('docs-sidebar'); const sidebar = document.getElementById("docs-sidebar");
const overlay = document.getElementById('docs-overlay'); const overlay = document.getElementById("docs-overlay");
if (sidebar && overlay) { if (sidebar && overlay) {
sidebar.classList.toggle('translate-x-0'); sidebar.classList.toggle("translate-x-0");
sidebar.classList.toggle('-translate-x-full'); sidebar.classList.toggle("-translate-x-full");
overlay.classList.toggle('hidden'); overlay.classList.toggle("hidden");
} }
} }
function initializeDocsSearch(): void { function initializeDocsSearch(): void {
const searchInput = document.getElementById('docs-search') as HTMLInputElement; const searchInput = document.getElementById(
const searchResults = document.getElementById('docs-search-results'); "docs-search",
) as HTMLInputElement;
const searchResults = document.getElementById("docs-search-results");
if (!searchInput || !searchResults) return; if (!searchInput || !searchResults) return;
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null; let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
searchInput.addEventListener('input', () => { searchInput.addEventListener("input", () => {
const query = searchInput.value.trim(); const query = searchInput.value.trim();
if (docsSearchTimeout) { if (docsSearchTimeout) {
clearTimeout(docsSearchTimeout); clearTimeout(docsSearchTimeout);
} }
if (query.length < 2) { if (query.length < 2) {
searchResults.innerHTML = ''; searchResults.innerHTML = "";
searchResults.classList.add('hidden'); searchResults.classList.add("hidden");
return; return;
} }
docsSearchTimeout = setTimeout(() => { docsSearchTimeout = setTimeout(() => {
performDocsSearch(query); performDocsSearch(query);
}, 300); }, 300);
}); });
} }
function performDocsSearch(query: string): void { function performDocsSearch(query: string): void {
const searchResults = document.getElementById('docs-search-results'); const searchResults = document.getElementById("docs-search-results");
if (!searchResults) return; if (!searchResults) return;
if (!(window as any).lunr) { if (!(window as any).lunr) {
console.warn('Lunr.js not loaded'); console.warn("Lunr.js not loaded");
return; return;
}
try {
const idx = (window as any).lunrIndex;
if (!idx) {
searchResults.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
searchResults.classList.remove("hidden");
return;
} }
try { const results = idx.search(query);
const idx = (window as any).lunrIndex;
if (!idx) {
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
searchResults.classList.remove('hidden');
return;
}
const results = idx.search(query); if (results.length === 0) {
searchResults.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
} else {
searchResults.innerHTML = results
.slice(0, 10)
.map((result: { ref: string }) => {
const doc = (window as any).docsData?.[result.ref];
if (!doc) return "";
if (results.length === 0) { return `
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
} else {
searchResults.innerHTML = results.slice(0, 10).map((result: { ref: string }) => {
const doc = (window as any).docsData?.[result.ref];
if (!doc) return '';
return `
<a href="${result.ref}" class="block p-2 hover:bg-opacity-50 transition-colors" style="background-color: var(--bg-secondary)"> <a href="${result.ref}" class="block p-2 hover:bg-opacity-50 transition-colors" style="background-color: var(--bg-secondary)">
<p class="font-medium text-sm" style="color: var(--text-primary)">${doc.title || result.ref}</p> <p class="font-medium text-sm" style="color: var(--text-primary)">${doc.title || result.ref}</p>
${doc.section ? `<p class="text-xs" style="color: var(--text-secondary)">${doc.section}</p>` : ''} ${doc.section ? `<p class="text-xs" style="color: var(--text-secondary)">${doc.section}</p>` : ""}
</a> </a>
`; `;
}).join(''); })
} .join("");
searchResults.classList.remove('hidden');
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
searchResults.classList.remove('hidden');
} }
searchResults.classList.remove("hidden");
} catch (error) {
console.error("Search error:", error);
searchResults.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
searchResults.classList.remove("hidden");
}
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", () => {
initializeDocsSearch(); initializeDocsSearch();
}); });
(window as any).toggleSidebar = toggleSidebar; (window as any).toggleSidebar = toggleSidebar;
+85 -85
View File
@@ -1,137 +1,137 @@
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const div = document.createElement('div'); const div = document.createElement("div");
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function querySelector<T extends Element>(selector: string): T | null { function querySelector<T extends Element>(selector: string): T | null {
return document.querySelector<T>(selector); return document.querySelector<T>(selector);
} }
function querySelectorAll<T extends Element>(selector: string): NodeListOf<T> { function querySelectorAll<T extends Element>(selector: string): NodeListOf<T> {
return document.querySelectorAll<T>(selector); return document.querySelectorAll<T>(selector);
} }
function getElementById<T extends HTMLElement>(id: string): T | null { function getElementById<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null; return document.getElementById(id) as T | null;
} }
function createElement<K extends keyof HTMLElementTagNameMap>( function createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K, tagName: K,
attributes?: Record<string, string>, attributes?: Record<string, string>,
children?: (string | Node)[] children?: (string | Node)[],
): HTMLElementTagNameMap[K] { ): HTMLElementTagNameMap[K] {
const element = document.createElement(tagName); const element = document.createElement(tagName);
if (attributes) { if (attributes) {
Object.entries(attributes).forEach(([key, value]) => { Object.entries(attributes).forEach(([key, value]) => {
if (key === 'className') { if (key === "className") {
element.className = value; element.className = value;
} else if (key === 'dataset') { } else if (key === "dataset") {
Object.entries(JSON.parse(value)).forEach(([dataKey, dataValue]) => { Object.entries(JSON.parse(value)).forEach(([dataKey, dataValue]) => {
element.dataset[dataKey] = String(dataValue); element.dataset[dataKey] = String(dataValue);
});
} else {
element.setAttribute(key, value);
}
}); });
} } else {
element.setAttribute(key, value);
}
});
}
if (children) { if (children) {
children.forEach(child => { children.forEach((child) => {
if (typeof child === 'string') { if (typeof child === "string") {
element.appendChild(document.createTextNode(child)); element.appendChild(document.createTextNode(child));
} else { } else {
element.appendChild(child); element.appendChild(child);
} }
}); });
} }
return element; return element;
} }
function showElement(element: HTMLElement | null): void { function showElement(element: HTMLElement | null): void {
if (element) { if (element) {
element.classList.remove('hidden'); element.classList.remove("hidden");
} }
} }
function hideElement(element: HTMLElement | null): void { function hideElement(element: HTMLElement | null): void {
if (element) { if (element) {
element.classList.add('hidden'); element.classList.add("hidden");
} }
} }
function toggleElement(element: HTMLElement | null): void { function toggleElement(element: HTMLElement | null): void {
if (element) { if (element) {
element.classList.toggle('hidden'); element.classList.toggle("hidden");
} }
} }
function setTextContent(element: HTMLElement | null, text: string): void { function setTextContent(element: HTMLElement | null, text: string): void {
if (element) { if (element) {
element.textContent = text; element.textContent = text;
} }
} }
function setInnerHTML(element: HTMLElement | null, html: string): void { function setInnerHTML(element: HTMLElement | null, html: string): void {
if (element) { if (element) {
element.innerHTML = html; element.innerHTML = html;
} }
} }
function addClass(element: HTMLElement | null, className: string): void { function addClass(element: HTMLElement | null, className: string): void {
if (element) { if (element) {
element.classList.add(className); element.classList.add(className);
} }
} }
function removeClass(element: HTMLElement | null, className: string): void { function removeClass(element: HTMLElement | null, className: string): void {
if (element) { if (element) {
element.classList.remove(className); element.classList.remove(className);
} }
} }
function toggleClass(element: HTMLElement | null, className: string): void { function toggleClass(element: HTMLElement | null, className: string): void {
if (element) { if (element) {
element.classList.toggle(className); element.classList.toggle(className);
} }
} }
function hasClass(element: HTMLElement | null, className: string): boolean { function hasClass(element: HTMLElement | null, className: string): boolean {
return element ? element.classList.contains(className) : false; return element ? element.classList.contains(className) : false;
} }
(window as any).dom = { (window as any).dom = {
escapeHtml, escapeHtml,
querySelector, querySelector,
querySelectorAll, querySelectorAll,
getElementById, getElementById,
createElement, createElement,
showElement, showElement,
hideElement, hideElement,
toggleElement, toggleElement,
setTextContent, setTextContent,
setInnerHTML, setInnerHTML,
addClass, addClass,
removeClass, removeClass,
toggleClass, toggleClass,
hasClass hasClass,
}; };
export { export {
escapeHtml, escapeHtml,
querySelector, querySelector,
querySelectorAll, querySelectorAll,
getElementById, getElementById,
createElement, createElement,
showElement, showElement,
hideElement, hideElement,
toggleElement, toggleElement,
setTextContent, setTextContent,
setInnerHTML, setInnerHTML,
addClass, addClass,
removeClass, removeClass,
toggleClass, toggleClass,
hasClass hasClass,
}; };
+64 -58
View File
@@ -1,82 +1,88 @@
// Header functionality // Header functionality
const toggleThemeDropdown = (): void => { const toggleThemeDropdown = (): void => {
const dropdown = document.getElementById('theme-dropdown'); const dropdown = document.getElementById("theme-dropdown");
if (dropdown) { if (dropdown) {
dropdown.classList.toggle('hidden'); dropdown.classList.toggle("hidden");
// Close user menu if open // Close user menu if open
const userMenu = document.getElementById('user-menu'); const userMenu = document.getElementById("user-menu");
if (userMenu && !dropdown.classList.contains('hidden')) { if (userMenu && !dropdown.classList.contains("hidden")) {
userMenu.classList.add('hidden'); userMenu.classList.add("hidden");
}
} }
}
}; };
const toggleUserMenu = (): void => { const toggleUserMenu = (): void => {
const menu = document.getElementById('user-menu'); const menu = document.getElementById("user-menu");
if (menu) { if (menu) {
menu.classList.toggle('hidden'); menu.classList.toggle("hidden");
// Close theme dropdown if open // Close theme dropdown if open
const themeDropdown = document.getElementById('theme-dropdown'); const themeDropdown = document.getElementById("theme-dropdown");
if (themeDropdown && !menu.classList.contains('hidden')) { if (themeDropdown && !menu.classList.contains("hidden")) {
themeDropdown.classList.add('hidden'); themeDropdown.classList.add("hidden");
}
} }
}
}; };
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) { if ((window as any).applyTheme) {
(window as any).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");
if (token) { if (token) {
fetch('/api/auth/theme', { fetch("/api/auth/theme", {
method: 'PUT', method: "PUT",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'Authorization': `Bearer ${token}` Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ theme }) body: JSON.stringify({ theme }),
}).catch(err => console.log('Theme save failed', err)); }).catch((err) => console.log("Theme save failed", err));
} }
// Close dropdown // Close dropdown
const dropdown = document.getElementById('theme-dropdown'); const dropdown = document.getElementById("theme-dropdown");
if (dropdown) { if (dropdown) {
dropdown.classList.add('hidden'); dropdown.classList.add("hidden");
} }
}; };
const logout = (): void => { const logout = (): void => {
localStorage.removeItem('token'); localStorage.removeItem("token");
localStorage.removeItem('user'); localStorage.removeItem("user");
window.location.href = '/'; window.location.href = "/";
}; };
// Close dropdowns when clicking outside // Close dropdowns when clicking outside
document.addEventListener('click', (e) => { document.addEventListener("click", (e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const themeDropdown = document.getElementById('theme-dropdown'); const themeDropdown = document.getElementById("theme-dropdown");
const userMenu = document.getElementById('user-menu'); const userMenu = document.getElementById("user-menu");
const themeButton = target?.closest('button[onclick="toggleThemeDropdown()"]'); const themeButton = target?.closest(
const userButton = target?.closest('button[onclick="toggleUserMenu()"]'); 'button[onclick="toggleThemeDropdown()"]',
);
if (!themeButton && themeDropdown && !themeDropdown.classList.contains('hidden')) { const userButton = target?.closest('button[onclick="toggleUserMenu()"]');
if (!themeDropdown.contains(target)) {
themeDropdown.classList.add('hidden'); if (
} !themeButton &&
themeDropdown &&
!themeDropdown.classList.contains("hidden")
) {
if (!themeDropdown.contains(target)) {
themeDropdown.classList.add("hidden");
} }
}
if (!userButton && userMenu && !userMenu.classList.contains('hidden')) {
if (!userMenu.contains(target)) { if (!userButton && userMenu && !userMenu.classList.contains("hidden")) {
userMenu.classList.add('hidden'); if (!userMenu.contains(target)) {
} userMenu.classList.add("hidden");
} }
}
}); });
// Make functions available globally // Make functions available globally
+478 -399
View File
File diff suppressed because it is too large Load Diff
+129 -100
View File
@@ -1,31 +1,34 @@
async function loadUnlinkedBooks(): Promise<void> { async function loadUnlinkedBooks(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/sync/unlinked-books', { const response = await fetch("/api/sync/unlinked-books", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
renderUnlinkedBooks(data.unlinked || []); renderUnlinkedBooks(data.unlinked || []);
}
} catch (error) {
console.error('Failed to load unlinked books:', error);
} }
} catch (error) {
console.error("Failed to load unlinked books:", error);
}
} }
function renderUnlinkedBooks(books: UnlinkedBookData[]): void { function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
const container = document.getElementById('unlinked-books-list'); const container = document.getElementById("unlinked-books-list");
if (!container) return; if (!container) return;
if (books.length === 0) { if (books.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>'; container.innerHTML =
return; '<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>';
} return;
}
container.innerHTML = books.map(book => ` container.innerHTML = books
.map(
(book) => `
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
@@ -38,10 +41,15 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button> <button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
</div> </div>
</div> </div>
${book.potential_matches && book.potential_matches.length > 0 ? ` ${
book.potential_matches && book.potential_matches.length > 0
? `
<div class="mt-3 pt-3 border-t" style="border-color: var(--border)"> <div class="mt-3 pt-3 border-t" style="border-color: var(--border)">
<p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p> <p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p>
${book.potential_matches.slice(0, 3).map(match => ` ${book.potential_matches
.slice(0, 3)
.map(
(match) => `
<div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)"> <div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)">
<div> <div>
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p> <p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
@@ -49,106 +57,125 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
</div> </div>
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button> <button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
</div> </div>
`).join('')} `,
)
.join("")}
</div> </div>
` : ''} `
: ""
}
</div> </div>
`).join(''); `,
)
.join("");
} }
async function linkBook(progressId: string, mediaItemId: string): Promise<void> { async function linkBook(
const token = localStorage.getItem('token'); progressId: string,
if (!token) return; mediaItemId: string,
): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const response = await fetch('/api/sync/link-book', { const response = await fetch("/api/sync/link-book", {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ progress_id: progressId, media_item_id: mediaItemId }) body: JSON.stringify({
}); progress_id: progressId,
media_item_id: mediaItemId,
}),
});
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Book linked successfully'); (window as any).showToast.success("Book linked successfully");
} }
loadUnlinkedBooks(); loadUnlinkedBooks();
} else { } else {
const error = await response.json(); const error = await response.json();
if ((window as any).showToast?.error) { if ((window as any).showToast?.error) {
(window as any).showToast.error(error.error || 'Failed to link book'); (window as any).showToast.error(error.error || "Failed to link book");
} }
}
} catch (error) {
console.error('Failed to link book:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to link book');
}
} }
} catch (error) {
console.error("Failed to link book:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to link book");
}
}
} }
async function autoLinkBooks(): Promise<void> { async function autoLinkBooks(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
if (!confirm('Auto-link all books with high confidence matches?')) return; if (!confirm("Auto-link all books with high confidence matches?")) return;
try { try {
const response = await fetch('/api/sync/auto-link', { const response = await fetch("/api/sync/auto-link", {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ confidence_threshold: 0.9 }) body: JSON.stringify({ confidence_threshold: 0.9 }),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success(`Auto-linked ${data.linked_count || 0} books`); (window as any).showToast.success(
} `Auto-linked ${data.linked_count || 0} books`,
loadUnlinkedBooks(); );
} }
} catch (error) { loadUnlinkedBooks();
console.error('Failed to auto-link:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to auto-link books');
}
} }
} catch (error) {
console.error("Failed to auto-link:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to auto-link books");
}
}
} }
async function getSuggestions(progressId: string): Promise<void> { async function getSuggestions(progressId: string): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch(`/api/sync/suggestions/${progressId}`, { const response = await fetch(`/api/sync/suggestions/${progressId}`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const suggestions = await response.json(); const suggestions = await response.json();
showSuggestionsModal(progressId, suggestions); showSuggestionsModal(progressId, suggestions);
}
} catch (error) {
console.error('Failed to get suggestions:', error);
} }
} catch (error) {
console.error("Failed to get suggestions:", error);
}
} }
function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void { function showSuggestionsModal(
const modal = document.getElementById('match-modal'); progressId: string,
const content = document.getElementById('match-modal-content'); suggestions: PotentialMatchData[],
): void {
const modal = document.getElementById("match-modal");
const content = document.getElementById("match-modal-content");
if (!modal || !content) return; if (!modal || !content) return;
content.innerHTML = ` content.innerHTML = `
<div class="p-4"> <div class="p-4">
<h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3> <h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3>
<div class="space-y-2"> <div class="space-y-2">
${suggestions.map(s => ` ${suggestions
.map(
(s) => `
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50" <div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
style="background-color: var(--bg-primary); border-color: var(--border)" style="background-color: var(--bg-primary); border-color: var(--border)"
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();"> onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
@@ -156,20 +183,22 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p> <p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p> <p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
</div> </div>
`).join('')} `,
)
.join("")}
</div> </div>
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button> <button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
</div> </div>
`; `;
modal.classList.remove('hidden'); modal.classList.remove("hidden");
} }
function hideMatchModal(): void { function hideMatchModal(): void {
const modal = document.getElementById('match-modal'); const modal = document.getElementById("match-modal");
if (modal) { if (modal) {
modal.classList.add('hidden'); modal.classList.add("hidden");
} }
} }
(window as any).loadUnlinkedBooks = loadUnlinkedBooks; (window as any).loadUnlinkedBooks = loadUnlinkedBooks;
+110 -99
View File
@@ -3,174 +3,185 @@
// Validation check functions // Validation check functions
function hasMinimumLength(password: string): boolean { function hasMinimumLength(password: string): boolean {
return password.length >= 8; return password.length >= 8;
} }
function hasUppercase(password: string): boolean { function hasUppercase(password: string): boolean {
return /[A-Z]/.test(password); return /[A-Z]/.test(password);
} }
function hasLowercase(password: string): boolean { function hasLowercase(password: string): boolean {
return /[a-z]/.test(password); return /[a-z]/.test(password);
} }
function hasNumber(password: string): boolean { function hasNumber(password: string): boolean {
return /[0-9]/.test(password); return /[0-9]/.test(password);
} }
function hasSpecialChar(password: string): boolean { function hasSpecialChar(password: string): boolean {
return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
} }
function passwordsMatch(password: string, confirm: string): boolean { function passwordsMatch(password: string, confirm: string): boolean {
if (!password && !confirm) { if (!password && !confirm) {
return false; return false;
} }
return password === confirm; return password === confirm;
} }
function hasUsername(username: string): boolean { function hasUsername(username: string): boolean {
return username.trim().length > 0; return username.trim().length > 0;
} }
function hasEmail(email: string): boolean { function hasEmail(email: string): boolean {
return email.trim().length > 0; return email.trim().length > 0;
} }
// UI update functions // UI update functions
function updateRequirementStatus(elementId: string, passed: boolean): void { function updateRequirementStatus(elementId: string, passed: boolean): void {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) { if (!element) {
return; return;
} }
const icon = element.querySelector('.requirement-icon'); const icon = element.querySelector(".requirement-icon");
if (!icon) { if (!icon) {
return; return;
} }
if (passed) { if (passed) {
icon.textContent = '✓'; icon.textContent = "✓";
icon.className = 'requirement-icon text-green-500'; icon.className = "requirement-icon text-green-500";
element.style.color = 'var(--text-primary)'; element.style.color = "var(--text-primary)";
} else { } else {
icon.textContent = '○'; icon.textContent = "○";
icon.className = 'requirement-icon'; icon.className = "requirement-icon";
element.style.color = 'var(--text-secondary)'; element.style.color = "var(--text-secondary)";
} }
} }
function updateSubmitButton(allPassed: boolean): void { function updateSubmitButton(allPassed: boolean): void {
const button = document.getElementById('register-btn') as HTMLButtonElement; const button = document.getElementById("register-btn") as HTMLButtonElement;
if (!button) { if (!button) {
return; return;
} }
if (allPassed) { if (allPassed) {
button.disabled = false; button.disabled = false;
button.classList.remove('opacity-50', 'cursor-not-allowed'); button.classList.remove("opacity-50", "cursor-not-allowed");
} else { } else {
button.disabled = true; button.disabled = true;
button.classList.add('opacity-50', 'cursor-not-allowed'); button.classList.add("opacity-50", "cursor-not-allowed");
} }
} }
// Main validation orchestrator // Main validation orchestrator
function validateAll(): void { function validateAll(): void {
const passwordField = document.getElementById('password') as HTMLInputElement; const passwordField = document.getElementById("password") as HTMLInputElement;
const confirmField = document.getElementById('confirm-password') as HTMLInputElement; const confirmField = document.getElementById(
const usernameField = document.getElementById('username') as HTMLInputElement; "confirm-password",
const emailField = document.getElementById('email') as HTMLInputElement; ) as HTMLInputElement;
const usernameField = document.getElementById("username") as HTMLInputElement;
const emailField = document.getElementById("email") as HTMLInputElement;
if (!passwordField || !confirmField || !usernameField || !emailField) { if (!passwordField || !confirmField || !usernameField || !emailField) {
return; return;
} }
const password = passwordField.value; const password = passwordField.value;
const confirm = confirmField.value; const confirm = confirmField.value;
const username = usernameField.value; const username = usernameField.value;
const email = emailField.value; const email = emailField.value;
// Check password requirements // Check password requirements
const hasLen = hasMinimumLength(password); const hasLen = hasMinimumLength(password);
const hasUpper = hasUppercase(password); const hasUpper = hasUppercase(password);
const hasLower = hasLowercase(password); const hasLower = hasLowercase(password);
const hasNum = hasNumber(password); const hasNum = hasNumber(password);
const hasSpecial = hasSpecialChar(password); const hasSpecial = hasSpecialChar(password);
const doMatch = passwordsMatch(password, confirm); const doMatch = passwordsMatch(password, confirm);
const hasUser = hasUsername(username); const hasUser = hasUsername(username);
const hasEmailAddr = hasEmail(email); const hasEmailAddr = hasEmail(email);
// Update requirement indicators // Update requirement indicators
updateRequirementStatus('req-length', hasLen); updateRequirementStatus("req-length", hasLen);
updateRequirementStatus('req-upper', hasUpper); updateRequirementStatus("req-upper", hasUpper);
updateRequirementStatus('req-lower', hasLower); updateRequirementStatus("req-lower", hasLower);
updateRequirementStatus('req-number', hasNum); updateRequirementStatus("req-number", hasNum);
updateRequirementStatus('req-special', hasSpecial); updateRequirementStatus("req-special", hasSpecial);
updateRequirementStatus('req-match', doMatch); updateRequirementStatus("req-match", doMatch);
// Enable/disable submit button // Enable/disable submit button
const allPassed = hasLen && hasUpper && hasLower && hasNum && const allPassed =
hasSpecial && doMatch && hasUser && hasEmailAddr; hasLen &&
updateSubmitButton(allPassed); hasUpper &&
hasLower &&
hasNum &&
hasSpecial &&
doMatch &&
hasUser &&
hasEmailAddr;
updateSubmitButton(allPassed);
} }
// Debounce function to avoid excessive validation calls // Debounce function to avoid excessive validation calls
let debounceTimer: number | null = null; let debounceTimer: number | null = null;
function debouncedValidation(): void { function debouncedValidation(): void {
if (debounceTimer !== null) { if (debounceTimer !== null) {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
} }
debounceTimer = window.setTimeout(() => { debounceTimer = window.setTimeout(() => {
validateAll(); validateAll();
debounceTimer = null; debounceTimer = null;
}, 100); }, 100);
} }
// Event handlers (PASSIVE - no preventDefault, doesn't block password managers) // Event handlers (PASSIVE - no preventDefault, doesn't block password managers)
function onPasswordInput(): void { function onPasswordInput(): void {
debouncedValidation(); debouncedValidation();
} }
function onConfirmInput(): void { function onConfirmInput(): void {
debouncedValidation(); debouncedValidation();
} }
function onUsernameInput(): void { function onUsernameInput(): void {
debouncedValidation(); debouncedValidation();
} }
function onEmailInput(): void { function onEmailInput(): void {
debouncedValidation(); debouncedValidation();
} }
// Initialization // Initialization
function initPasswordValidation(): void { function initPasswordValidation(): void {
const passwordField = document.getElementById('password') as HTMLInputElement; const passwordField = document.getElementById("password") as HTMLInputElement;
const confirmField = document.getElementById('confirm-password') as HTMLInputElement; const confirmField = document.getElementById(
const usernameField = document.getElementById('username') as HTMLInputElement; "confirm-password",
const emailField = document.getElementById('email') as HTMLInputElement; ) as HTMLInputElement;
const usernameField = document.getElementById("username") as HTMLInputElement;
const emailField = document.getElementById("email") as HTMLInputElement;
if (!passwordField || !confirmField || !usernameField || !emailField) { if (!passwordField || !confirmField || !usernameField || !emailField) {
return; return;
} }
// Add passive event listeners - don't prevent default, don't block password managers // Add passive event listeners - don't prevent default, don't block password managers
passwordField.addEventListener('input', onPasswordInput, { passive: true }); passwordField.addEventListener("input", onPasswordInput, { passive: true });
passwordField.addEventListener('paste', onPasswordInput, { passive: true }); passwordField.addEventListener("paste", onPasswordInput, { passive: true });
confirmField.addEventListener('input', onConfirmInput, { passive: true }); confirmField.addEventListener("input", onConfirmInput, { passive: true });
confirmField.addEventListener('paste', onConfirmInput, { passive: true }); confirmField.addEventListener("paste", onConfirmInput, { passive: true });
usernameField.addEventListener('input', onUsernameInput, { passive: true }); usernameField.addEventListener("input", onUsernameInput, { passive: true });
usernameField.addEventListener('paste', onUsernameInput, { passive: true }); usernameField.addEventListener("paste", onUsernameInput, { passive: true });
emailField.addEventListener('input', onEmailInput, { passive: true }); emailField.addEventListener("input", onEmailInput, { passive: true });
emailField.addEventListener('paste', onEmailInput, { passive: true }); emailField.addEventListener("paste", onEmailInput, { passive: true });
// Initial validation // Initial validation
validateAll(); validateAll();
} }
// Export for use in template // Export for use in template
+120 -115
View File
@@ -1,170 +1,175 @@
async function refreshQueue(): Promise<void> { async function refreshQueue(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/queue/all', { const response = await fetch("/api/queue/all", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
renderQueueItems(data.items || []); renderQueueItems(data.items || []);
}
} catch (error) {
console.error('Failed to refresh queue:', error);
} }
} catch (error) {
console.error("Failed to refresh queue:", error);
}
} }
async function processPendingItems(): Promise<void> { async function processPendingItems(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/queue/process', { const response = await fetch("/api/queue/process", {
method: 'POST', method: "POST",
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Processing queue items'); (window as any).showToast.success("Processing queue items");
} }
refreshQueue(); refreshQueue();
}
} catch (error) {
console.error('Failed to process queue:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to process queue');
}
} }
} catch (error) {
console.error("Failed to process queue:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to process queue");
}
}
} }
async function clearFailedItems(): Promise<void> { async function clearFailedItems(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
if (!confirm('Are you sure you want to clear all failed items?')) return; if (!confirm("Are you sure you want to clear all failed items?")) return;
try { try {
const response = await fetch('/api/queue/failed', { const response = await fetch("/api/queue/failed", {
method: 'DELETE', method: "DELETE",
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Failed items cleared'); (window as any).showToast.success("Failed items cleared");
} }
refreshQueue(); refreshQueue();
}
} catch (error) {
console.error('Failed to clear failed items:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to clear items');
}
} }
} catch (error) {
console.error("Failed to clear failed items:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to clear items");
}
}
} }
async function clearAllItems(): Promise<void> { async function clearAllItems(): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
if (!confirm('Are you sure you want to clear all queue items?')) return; if (!confirm("Are you sure you want to clear all queue items?")) return;
try { try {
const response = await fetch('/api/queue/all', { const response = await fetch("/api/queue/all", {
method: 'DELETE', method: "DELETE",
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Queue cleared'); (window as any).showToast.success("Queue cleared");
} }
refreshQueue(); refreshQueue();
}
} catch (error) {
console.error('Failed to clear queue:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to clear queue');
}
} }
} catch (error) {
console.error("Failed to clear queue:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to clear queue");
}
}
} }
async function retryQueueItem(itemId: string): Promise<void> { async function retryQueueItem(itemId: string): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch(`/api/queue/items/${itemId}/retry`, { const response = await fetch(`/api/queue/items/${itemId}/retry`, {
method: 'POST', method: "POST",
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Item queued for retry'); (window as any).showToast.success("Item queued for retry");
} }
refreshQueue(); refreshQueue();
}
} catch (error) {
console.error('Failed to retry item:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to retry item');
}
} }
} catch (error) {
console.error("Failed to retry item:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to retry item");
}
}
} }
async function deleteQueueItem(itemId: string): Promise<void> { async function deleteQueueItem(itemId: string): Promise<void> {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
const response = await fetch(`/api/queue/items/${itemId}`, { const response = await fetch(`/api/queue/items/${itemId}`, {
method: 'DELETE', method: "DELETE",
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
if ((window as any).showToast?.success) { if ((window as any).showToast?.success) {
(window as any).showToast.success('Item deleted'); (window as any).showToast.success("Item deleted");
} }
refreshQueue(); refreshQueue();
}
} catch (error) {
console.error('Failed to delete item:', error);
if ((window as any).showToast?.error) {
(window as any).showToast.error('Failed to delete item');
}
} }
} catch (error) {
console.error("Failed to delete item:", error);
if ((window as any).showToast?.error) {
(window as any).showToast.error("Failed to delete item");
}
}
} }
function renderQueueItems(items: QueueItemResponse[]): void { function renderQueueItems(items: QueueItemResponse[]): void {
const container = document.getElementById('queue-items'); const container = document.getElementById("queue-items");
if (!container) return; if (!container) return;
if (items.length === 0) { if (items.length === 0) {
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">Queue is empty</p>'; container.innerHTML =
return; '<p class="text-center p-4" style="color: var(--text-secondary)">Queue is empty</p>';
} return;
}
container.innerHTML = items.map(item => ` container.innerHTML = items
.map(
(item) => `
<div class="p-3 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="p-3 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<p class="font-medium" style="color: var(--text-primary)">${item.media_title || 'Unknown'}</p> <p class="font-medium" style="color: var(--text-primary)">${item.media_title || "Unknown"}</p>
<p class="text-sm" style="color: var(--text-secondary)">${item.status} - ${item.sync_type}</p> <p class="text-sm" style="color: var(--text-secondary)">${item.status} - ${item.sync_type}</p>
<p class="text-xs" style="color: var(--text-secondary)">Attempts: ${item.attempts}/${item.max_attempts}</p> <p class="text-xs" style="color: var(--text-secondary)">Attempts: ${item.attempts}/${item.max_attempts}</p>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
${item.status === 'failed' ? `<button onclick="window.retryQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Retry</button>` : ''} ${item.status === "failed" ? `<button onclick="window.retryQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Retry</button>` : ""}
<button onclick="window.deleteQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button> <button onclick="window.deleteQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
</div> </div>
</div> </div>
${item.error_message ? `<p class="text-xs mt-2" style="color: var(--accent)">${item.error_message}</p>` : ''} ${item.error_message ? `<p class="text-xs mt-2" style="color: var(--accent)">${item.error_message}</p>` : ""}
</div> </div>
`).join(''); `,
)
.join("");
} }
(window as any).refreshQueue = refreshQueue; (window as any).refreshQueue = refreshQueue;
+174 -158
View File
@@ -3,177 +3,188 @@ const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 2; const SEARCH_MIN_CHARS = 2;
function initializeSearch(): void { function initializeSearch(): void {
const searchInput = document.getElementById('header-search') as HTMLInputElement | null; const searchInput = document.getElementById(
if (!searchInput) { "header-search",
console.warn('Search input not found'); ) as HTMLInputElement | null;
return; if (!searchInput) {
console.warn("Search input not found");
return;
}
searchInput.addEventListener("input", handleSearchInput);
searchInput.addEventListener("keydown", handleSearchKeydown);
searchInput.addEventListener("focus", () => {
if (searchInput.value.length >= SEARCH_MIN_CHARS) {
performSearch(searchInput.value);
} }
});
searchInput.addEventListener('input', handleSearchInput); document.addEventListener("click", (e: MouseEvent) => {
searchInput.addEventListener('keydown', handleSearchKeydown); const searchResults = document.getElementById("search-results");
searchInput.addEventListener('focus', () => { const searchInputEl = document.getElementById("header-search");
if (searchInput.value.length >= SEARCH_MIN_CHARS) {
performSearch(searchInput.value);
}
});
document.addEventListener('click', (e: MouseEvent) => { if (
const searchResults = document.getElementById('search-results'); searchResults &&
const searchInputEl = document.getElementById('header-search'); !searchResults.contains(e.target as Node) &&
e.target !== searchInputEl
if (searchResults && !searchResults.contains(e.target as Node) && e.target !== searchInputEl) { ) {
hideSearchResults(); hideSearchResults();
} }
}); });
} }
function handleSearchInput(e: Event): void { function handleSearchInput(e: Event): void {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
const query = target.value.trim(); const query = target.value.trim();
if (searchInputTimeout) { if (searchInputTimeout) {
clearTimeout(searchInputTimeout); clearTimeout(searchInputTimeout);
} }
if (query.length < SEARCH_MIN_CHARS) { if (query.length < SEARCH_MIN_CHARS) {
hideSearchResults(); hideSearchResults();
return; return;
} }
searchInputTimeout = setTimeout(() => { searchInputTimeout = setTimeout(() => {
performSearch(query); performSearch(query);
}, SEARCH_DEBOUNCE_MS); }, SEARCH_DEBOUNCE_MS);
} }
function handleSearchKeydown(e: KeyboardEvent): void { function handleSearchKeydown(e: KeyboardEvent): void {
const searchResults = document.getElementById('search-results'); const searchResults = document.getElementById("search-results");
if (!searchResults || searchResults.classList.contains('hidden')) { if (!searchResults || searchResults.classList.contains("hidden")) {
return; return;
} }
const items = searchResults.querySelectorAll('.search-result-item'); const items = searchResults.querySelectorAll(".search-result-item");
const currentIndex = parseInt(searchResults.dataset.selectedIndex || '-1'); const currentIndex = parseInt(searchResults.dataset.selectedIndex || "-1");
if (e.key === 'ArrowDown') { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
const nextIndex = Math.min(currentIndex + 1, items.length - 1); const nextIndex = Math.min(currentIndex + 1, items.length - 1);
selectSearchResult(items, nextIndex); selectSearchResult(items, nextIndex);
} else if (e.key === 'ArrowUp') { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const prevIndex = Math.max(currentIndex - 1, -1); const prevIndex = Math.max(currentIndex - 1, -1);
selectSearchResult(items, prevIndex); selectSearchResult(items, prevIndex);
} else if (e.key === 'Enter') { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (currentIndex >= 0 && items[currentIndex]) { if (currentIndex >= 0 && items[currentIndex]) {
const link = items[currentIndex].querySelector('a'); const link = items[currentIndex].querySelector("a");
if (link) link.click(); if (link) link.click();
}
} else if (e.key === 'Escape') {
hideSearchResults();
} }
} else if (e.key === "Escape") {
hideSearchResults();
}
} }
function selectSearchResult(items: NodeListOf<Element>, index: number): void { function selectSearchResult(items: NodeListOf<Element>, index: number): void {
items.forEach((item, i) => { items.forEach((item, i) => {
if (i === index) { if (i === index) {
item.classList.add('bg-opacity-80'); item.classList.add("bg-opacity-80");
} else { } else {
item.classList.remove('bg-opacity-80'); item.classList.remove("bg-opacity-80");
}
});
const searchResults = document.getElementById('search-results');
if (searchResults) {
searchResults.dataset.selectedIndex = index.toString();
} }
});
const searchResults = document.getElementById("search-results");
if (searchResults) {
searchResults.dataset.selectedIndex = index.toString();
}
} }
function performSearch(query: string): void { function performSearch(query: string): void {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) { if (!token) {
console.warn('No authentication token found'); console.warn("No authentication token found");
return; return;
} }
showSearchLoading(); showSearchLoading();
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, { fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
})
.then((response) => {
if (response.status === 404) {
return { error: "no results found", results: [] };
}
return response.json();
}) })
.then(response => { .then(
if (response.status === 404) { (
return { error: 'no results found', results: [] }; data:
} | { error?: string; results?: MediaItemSummary[] }
return response.json(); | MediaItemSummary[],
}) ) => {
.then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
hideSearchLoading(); hideSearchLoading();
if (data && 'error' in data && data.error === 'no results found') { if (data && "error" in data && data.error === "no results found") {
showNoResults(query); showNoResults(query);
} else if (Array.isArray(data) && data.length > 0) { } else if (Array.isArray(data) && data.length > 0) {
showSearchResults(data, query); showSearchResults(data, query);
} else if (Array.isArray(data)) { } else if (Array.isArray(data)) {
showNoResults(query); showNoResults(query);
} else { } else {
showNoResults(query); showNoResults(query);
} }
}) },
.catch(error => { )
hideSearchLoading(); .catch((error) => {
console.error('Search error:', error); hideSearchLoading();
showSearchError(); console.error("Search error:", error);
showSearchError();
}); });
} }
function showSearchLoading(): void { function showSearchLoading(): void {
createSearchResultsContainer(); createSearchResultsContainer();
const searchResults = document.getElementById('search-results'); const searchResults = document.getElementById("search-results");
if (!searchResults) return; if (!searchResults) return;
searchResults.innerHTML = ` searchResults.innerHTML = `
<div class="p-4 text-center" style="color: var(--text-secondary)"> <div class="p-4 text-center" style="color: var(--text-secondary)">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--accent)"></div> <div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--accent)"></div>
<p class="mt-2 text-sm">Searching...</p> <p class="mt-2 text-sm">Searching...</p>
</div> </div>
`; `;
searchResults.classList.remove('hidden'); searchResults.classList.remove("hidden");
} }
function hideSearchLoading(): void { function hideSearchLoading(): void {}
}
function showSearchResults(results: MediaItemSummary[], query: string): void { function showSearchResults(results: MediaItemSummary[], query: string): void {
createSearchResultsContainer(); createSearchResultsContainer();
const searchResults = document.getElementById('search-results'); const searchResults = document.getElementById("search-results");
if (!searchResults) return; if (!searchResults) return;
searchResults.dataset.selectedIndex = '-1'; searchResults.dataset.selectedIndex = "-1";
const libraryIconMap: Record<string, string> = { const libraryIconMap: Record<string, string> = {
'ebooks': '📚', ebooks: "📚",
'comics': '📖', comics: "📖",
'manga': '🗾' manga: "🗾",
}; };
let html = ` let html = `
<div class="p-3 border-b" style="border-color: var(--border)"> <div class="p-3 border-b" style="border-color: var(--border)">
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)"> <p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
${results.length} result${results.length !== 1 ? 's' : ''} for "${searchEscapeHtml(query)}" ${results.length} result${results.length !== 1 ? "s" : ""} for "${searchEscapeHtml(query)}"
</p> </p>
</div> </div>
<div class="max-h-96 overflow-y-auto"> <div class="max-h-96 overflow-y-auto">
`; `;
results.forEach((item, index) => { results.forEach((item, index) => {
const icon = libraryIconMap[item.library_type_name] || '📁'; const icon = libraryIconMap[item.library_type_name] || "📁";
const titleHtml = highlightMatch(item.title, query); const titleHtml = highlightMatch(item.title, query);
const authorHtml = item.author ? highlightMatch(item.author, query) : ''; const authorHtml = item.author ? highlightMatch(item.author, query) : "";
html += ` html += `
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer" <div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
style="border-color: var(--border); background-color: var(--bg-secondary)" style="border-color: var(--border); background-color: var(--bg-secondary)"
data-index="${index}"> data-index="${index}">
@@ -186,7 +197,7 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)"> <h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
${titleHtml} ${titleHtml}
</h4> </h4>
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''} ${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ""}
<p class="text-xs mt-1" style="color: var(--text-secondary)"> <p class="text-xs mt-1" style="color: var(--text-secondary)">
${searchEscapeHtml(item.library_name)} ${searchEscapeHtml(item.library_name)}
</p> </p>
@@ -195,9 +206,9 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
</a> </a>
</div> </div>
`; `;
}); });
html += ` html += `
</div> </div>
<div class="p-2 border-t text-center" style="border-color: var(--border)"> <div class="p-2 border-t text-center" style="border-color: var(--border)">
<p class="text-xs" style="color: var(--text-secondary)"> <p class="text-xs" style="color: var(--text-secondary)">
@@ -207,84 +218,89 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
</div> </div>
`; `;
searchResults.innerHTML = html; searchResults.innerHTML = html;
searchResults.classList.remove('hidden'); searchResults.classList.remove("hidden");
} }
function showNoResults(query: string): void { function showNoResults(query: string): void {
createSearchResultsContainer(); createSearchResultsContainer();
const searchResults = document.getElementById('search-results'); const searchResults = document.getElementById("search-results");
if (!searchResults) return; if (!searchResults) return;
searchResults.innerHTML = ` searchResults.innerHTML = `
<div class="p-4 text-center"> <div class="p-4 text-center">
<div class="text-4xl mb-2">🔍</div> <div class="text-4xl mb-2">🔍</div>
<p class="text-sm" style="color: var(--text-primary)">No results found for "${searchEscapeHtml(query)}"</p> <p class="text-sm" style="color: var(--text-primary)">No results found for "${searchEscapeHtml(query)}"</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p> <p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
</div> </div>
`; `;
searchResults.classList.remove('hidden'); searchResults.classList.remove("hidden");
} }
function showSearchError(): void { function showSearchError(): void {
createSearchResultsContainer(); createSearchResultsContainer();
const searchResults = document.getElementById('search-results'); const searchResults = document.getElementById("search-results");
if (!searchResults) return; if (!searchResults) return;
searchResults.innerHTML = ` searchResults.innerHTML = `
<div class="p-4 text-center"> <div class="p-4 text-center">
<div class="text-4xl mb-2">⚠️</div> <div class="text-4xl mb-2">⚠️</div>
<p class="text-sm" style="color: var(--text-primary)">Search error</p> <p class="text-sm" style="color: var(--text-primary)">Search error</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p> <p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p>
</div> </div>
`; `;
searchResults.classList.remove('hidden'); searchResults.classList.remove("hidden");
} }
function hideSearchResults(): void { function hideSearchResults(): void {
const searchResults = document.getElementById('search-results'); const searchResults = document.getElementById("search-results");
if (searchResults) { if (searchResults) {
searchResults.classList.add('hidden'); searchResults.classList.add("hidden");
} }
} }
function createSearchResultsContainer(): void { function createSearchResultsContainer(): void {
let searchResults = document.getElementById('search-results'); let searchResults = document.getElementById("search-results");
if (!searchResults) { if (!searchResults) {
searchResults = document.createElement('div'); searchResults = document.createElement("div");
searchResults.id = 'search-results'; searchResults.id = "search-results";
searchResults.className = 'hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border'; searchResults.className =
searchResults.style.cssText = 'background-color: var(--bg-secondary); border-color: var(--border)'; "hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border";
searchResults.style.cssText =
"background-color: var(--bg-secondary); border-color: var(--border)";
const searchInput = document.getElementById('header-search'); const searchInput = document.getElementById("header-search");
if (searchInput) { if (searchInput) {
const searchContainer = searchInput.closest('.relative'); const searchContainer = searchInput.closest(".relative");
if (searchContainer) { if (searchContainer) {
searchContainer.appendChild(searchResults); searchContainer.appendChild(searchResults);
} }
}
} }
}
} }
function highlightMatch(text: string, query: string): string { function highlightMatch(text: string, query: string): string {
if (!text) return ''; if (!text) return "";
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, 'gi'); const regex = new RegExp(`(${escapedQuery})`, "gi");
return searchEscapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>'); return searchEscapeHtml(text).replace(
regex,
'<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>',
);
} }
function searchEscapeHtml(text: string): string { function searchEscapeHtml(text: string): string {
const div = document.createElement('div'); const div = document.createElement("div");
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function selectLibraryAndBook(libraryId: string, bookId: string): void { function selectLibraryAndBook(libraryId: string, bookId: string): void {
localStorage.setItem('selectedLibrary', libraryId); localStorage.setItem("selectedLibrary", libraryId);
localStorage.setItem('selectedBook', bookId); localStorage.setItem("selectedBook", bookId);
hideSearchResults(); hideSearchResults();
} }
document.addEventListener('DOMContentLoaded', initializeSearch); document.addEventListener("DOMContentLoaded", initializeSearch);
(window as any).selectLibraryAndBook = selectLibraryAndBook; (window as any).selectLibraryAndBook = selectLibraryAndBook;
+39 -39
View File
@@ -1,83 +1,83 @@
function getToken(): string | null { function getToken(): string | null {
return localStorage.getItem('token'); return localStorage.getItem("token");
} }
function setToken(token: string): void { function setToken(token: string): void {
localStorage.setItem('token', token); localStorage.setItem("token", token);
} }
function removeToken(): void { function removeToken(): void {
localStorage.removeItem('token'); localStorage.removeItem("token");
} }
function getRefreshToken(): string | null { function getRefreshToken(): string | null {
return localStorage.getItem('refresh_token'); return localStorage.getItem("refresh_token");
} }
function setRefreshToken(token: string): void { function setRefreshToken(token: string): void {
localStorage.setItem('refresh_token', token); localStorage.setItem("refresh_token", token);
} }
function removeRefreshToken(): void { function removeRefreshToken(): void {
localStorage.removeItem('refresh_token'); localStorage.removeItem("refresh_token");
} }
function getTheme(): string { function getTheme(): string {
return localStorage.getItem('theme') || 'tokyo-night'; return localStorage.getItem("theme") || "tokyo-night";
} }
function setTheme(theme: string): void { function setTheme(theme: string): void {
localStorage.setItem('theme', theme); localStorage.setItem("theme", theme);
} }
function getSelectedLibrary(): string | null { function getSelectedLibrary(): string | null {
return localStorage.getItem('selectedLibrary'); return localStorage.getItem("selectedLibrary");
} }
function setSelectedLibrary(libraryId: string): void { function setSelectedLibrary(libraryId: string): void {
localStorage.setItem('selectedLibrary', libraryId); localStorage.setItem("selectedLibrary", libraryId);
} }
function getSelectedBook(): string | null { function getSelectedBook(): string | null {
return localStorage.getItem('selectedBook'); return localStorage.getItem("selectedBook");
} }
function setSelectedBook(bookId: string): void { function setSelectedBook(bookId: string): void {
localStorage.setItem('selectedBook', bookId); localStorage.setItem("selectedBook", bookId);
} }
function clearAll(): void { function clearAll(): void {
localStorage.clear(); localStorage.clear();
} }
(window as any).storage = { (window as any).storage = {
getToken, getToken,
setToken, setToken,
removeToken, removeToken,
getRefreshToken, getRefreshToken,
setRefreshToken, setRefreshToken,
removeRefreshToken, removeRefreshToken,
getTheme, getTheme,
setTheme, setTheme,
getSelectedLibrary, getSelectedLibrary,
setSelectedLibrary, setSelectedLibrary,
getSelectedBook, getSelectedBook,
setSelectedBook, setSelectedBook,
clearAll clearAll,
}; };
export { export {
getToken, getToken,
setToken, setToken,
removeToken, removeToken,
getRefreshToken, getRefreshToken,
setRefreshToken, setRefreshToken,
removeRefreshToken, removeRefreshToken,
getTheme, getTheme,
setTheme, setTheme,
getSelectedLibrary, getSelectedLibrary,
setSelectedLibrary, setSelectedLibrary,
getSelectedBook, getSelectedBook,
setSelectedBook, setSelectedBook,
clearAll clearAll,
}; };
+100 -93
View File
@@ -1,133 +1,140 @@
// Theme management functionality // Theme management functionality
type ThemeType = type ThemeType =
| 'tokyo-night' | "tokyo-night"
| 'dracula' | "dracula"
| 'nord' | "nord"
| 'solarized-dark' | "solarized-dark"
| 'monokai' | "monokai"
| 'one-dark-pro' | "one-dark-pro"
| 'material-dark' | "material-dark"
| 'catppuccin-mocha' | "catppuccin-mocha"
| 'catppuccin-macchiato' | "catppuccin-macchiato"
| 'catppuccin-frappe' | "catppuccin-frappe"
| 'catppuccin-latte'; | "catppuccin-latte";
const DEFAULT_THEME: ThemeType = 'tokyo-night'; const DEFAULT_THEME: ThemeType = "tokyo-night";
const THEME_STORAGE_KEY = 'theme'; const THEME_STORAGE_KEY = "theme";
const TOKEN_STORAGE_KEY = 'token'; const TOKEN_STORAGE_KEY = "token";
// Apply theme to document body // Apply theme to document body
const applyTheme = (theme: string): void => { const applyTheme = (theme: string): void => {
// Apply regular theme only // Apply regular theme only
document.body.className = `theme-${theme}`; document.body.className = `theme-${theme}`;
document.body.style.background = ''; document.body.style.background = "";
document.body.style.backgroundSize = ''; document.body.style.backgroundSize = "";
document.body.style.backgroundAttachment = ''; document.body.style.backgroundAttachment = "";
localStorage.setItem(THEME_STORAGE_KEY, theme); localStorage.setItem(THEME_STORAGE_KEY, theme);
}; };
// Load theme from localStorage or use default // Load theme from localStorage or use default
const loadTheme = (): void => { const loadTheme = (): void => {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null; const storedTheme = localStorage.getItem(
const theme = storedTheme || DEFAULT_THEME; THEME_STORAGE_KEY,
applyTheme(theme); ) as ThemeType | null;
const theme = storedTheme || DEFAULT_THEME;
applyTheme(theme);
}; };
// Handle theme change from user selection // Handle theme change from user selection
const changeTheme = async (): Promise<void> => { const changeTheme = async (): Promise<void> => {
const themeSelect = document.getElementById('theme-select') as HTMLSelectElement; const themeSelect = document.getElementById(
if (!themeSelect) return; "theme-select",
) as HTMLSelectElement;
const theme = themeSelect.value as ThemeType; if (!themeSelect) return;
applyTheme(theme);
const theme = themeSelect.value as ThemeType;
// Save to server if logged in applyTheme(theme);
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
if (!token) return; // Save to server if logged in
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
try { if (!token) return;
const response = await fetch('/api/auth/theme', {
method: 'PUT', try {
headers: { const response = await fetch("/api/auth/theme", {
'Content-Type': 'application/json', method: "PUT",
'Authorization': `Bearer ${token}` headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ theme }) Authorization: `Bearer ${token}`,
}); },
body: JSON.stringify({ theme }),
if (!response.ok) { });
console.log('Theme save failed');
} if (!response.ok) {
} catch (error) { console.log("Theme save failed");
console.log('Theme save failed', error);
} }
} catch (error) {
console.log("Theme save failed", error);
}
}; };
// Load user's theme from server if logged in // Load user's theme from server if logged in
const loadUserTheme = async (): Promise<void> => { const loadUserTheme = async (): Promise<void> => {
const token = localStorage.getItem(TOKEN_STORAGE_KEY); const token = localStorage.getItem(TOKEN_STORAGE_KEY);
if (!token) return; if (!token) return;
try { try {
const response = await fetch('/api/auth/profile', { const response = await fetch("/api/auth/profile", {
headers: { 'Authorization': `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` },
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.theme) { if (data.theme) {
applyTheme(data.theme as string); applyTheme(data.theme as string);
} }
}
} catch {
// Silently fail - user will get default theme
} }
} catch {
// Silently fail - user will get default theme
}
}; };
// Initialize theme system // Initialize theme system
const initializeTheme = (): void => { const initializeTheme = (): void => {
loadTheme(); loadTheme();
loadUserTheme(); loadUserTheme();
// Set theme select value to current theme // Set theme select value to current theme
const themeSelect = document.getElementById('theme-select') as HTMLSelectElement; const themeSelect = document.getElementById(
if (themeSelect) { "theme-select",
const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME; ) as HTMLSelectElement;
themeSelect.value = currentTheme; if (themeSelect) {
} const currentTheme =
localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
// Setup smooth scroll for anchor links themeSelect.value = currentTheme;
setupSmoothScroll(); }
// Setup smooth scroll for anchor links
setupSmoothScroll();
}; };
// Setup smooth scrolling for anchor links // Setup smooth scrolling for anchor links
const setupSmoothScroll = (): void => { const setupSmoothScroll = (): void => {
document.querySelectorAll('a[href^="#"]').forEach(anchor => { document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (e) => { anchor.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
const href = anchor.getAttribute('href'); const href = anchor.getAttribute("href");
if (!href) return; if (!href) return;
const target = document.querySelector(href); const target = document.querySelector(href);
if (target) { if (target) {
target.scrollIntoView({ target.scrollIntoView({
behavior: 'smooth', behavior: "smooth",
block: 'start' block: "start",
});
}
}); });
}
}); });
});
}; };
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', initializeTheme); document.addEventListener("DOMContentLoaded", initializeTheme);
} else { } else {
initializeTheme(); initializeTheme();
} }
} }
// Make changeTheme available globally for HTML onchange attribute // Make changeTheme available globally for HTML onchange attribute
+33 -33
View File
@@ -2,25 +2,25 @@
// 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";
// Update theme buttons (all buttons with changeThemeTo onclick) // Update theme buttons (all buttons with changeThemeTo onclick)
document.querySelectorAll('[onclick^="changeThemeTo"]').forEach(btn => { document.querySelectorAll('[onclick^="changeThemeTo"]').forEach((btn) => {
const onclick = btn.getAttribute('onclick') || ''; const onclick = btn.getAttribute("onclick") || "";
const match = onclick.match(/changeThemeTo\('(.+?)'\)/); const match = onclick.match(/changeThemeTo\('(.+?)'\)/);
if (match) { if (match) {
const theme = match[1]; const theme = match[1];
if (theme === currentTheme) { if (theme === currentTheme) {
// Active state - use CSS class instead of inline style // Active state - use CSS class instead of inline style
btn.classList.add('bg-theme-active'); btn.classList.add("bg-theme-active");
btn.classList.remove('bg-theme-inactive'); btn.classList.remove("bg-theme-inactive");
} else { } else {
// Inactive state // Inactive state
btn.classList.remove('bg-theme-active'); btn.classList.remove("bg-theme-active");
btn.classList.add('bg-theme-inactive'); btn.classList.add("bg-theme-inactive");
} }
} }
}); });
}; };
// Make function available globally // Make function available globally
@@ -29,27 +29,27 @@ const updateThemeIndicators = (): void => {
// Update on dropdown toggle // Update on dropdown toggle
const originalToggleThemeDropdown = (window as any).toggleThemeDropdown; const originalToggleThemeDropdown = (window as any).toggleThemeDropdown;
if (originalToggleThemeDropdown) { if (originalToggleThemeDropdown) {
(window as any).toggleThemeDropdown = () => { (window as any).toggleThemeDropdown = () => {
originalToggleThemeDropdown(); originalToggleThemeDropdown();
updateThemeIndicators(); updateThemeIndicators();
(window as any).updateWoodPanelingIndicators?.(); (window as any).updateWoodPanelingIndicators?.();
}; };
} }
// Update after theme changes // Update after theme changes
const originalChangeThemeTo = (window as any).changeThemeTo; const originalChangeThemeTo = (window as any).changeThemeTo;
if (originalChangeThemeTo) { if (originalChangeThemeTo) {
(window as any).changeThemeTo = (...args: unknown[]) => { (window as any).changeThemeTo = (...args: unknown[]) => {
originalChangeThemeTo(...args); originalChangeThemeTo(...args);
updateThemeIndicators(); updateThemeIndicators();
}; };
} }
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', updateThemeIndicators); document.addEventListener("DOMContentLoaded", updateThemeIndicators);
} else { } else {
updateThemeIndicators(); updateThemeIndicators();
} }
} }
+165 -155
View File
@@ -1,225 +1,235 @@
// Toast notification system for backend errors // Toast notification system for backend errors
// Displays toast notifications at the top of the page // Displays toast notifications at the top of the page
type ToastType = 'error' | 'success' | 'info'; type ToastType = "error" | "success" | "info";
const TOAST_DEFAULT_DURATION = 5000; const TOAST_DEFAULT_DURATION = 5000;
// Create toast container // Create toast container
const createToastContainer = (): HTMLElement => { const createToastContainer = (): HTMLElement => {
let container = document.getElementById('toast-container'); let container = document.getElementById("toast-container");
if (!container) { if (!container) {
container = document.createElement('div'); container = document.createElement("div");
container.id = 'toast-container'; container.id = "toast-container";
container.className = 'fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none'; container.className =
document.body.appendChild(container); "fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
} document.body.appendChild(container);
return container; }
return container;
}; };
// Escape HTML to prevent XSS // Escape HTML to prevent XSS
const toastEscapeHtml = (text: string): string => { const toastEscapeHtml = (text: string): string => {
const div = document.createElement('div'); const div = document.createElement("div");
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
}; };
// Get toast configuration by type // Get toast configuration by type
const getToastConfig = (type: ToastType) => { const getToastConfig = (type: ToastType) => {
const configs = { const configs = {
error: { error: {
bgClass: 'bg-red-500/90', bgClass: "bg-red-500/90",
icon: '❌' icon: "❌",
}, },
success: { success: {
bgClass: 'bg-green-500/90', bgClass: "bg-green-500/90",
icon: '✅' icon: "✅",
}, },
info: { info: {
bgClass: 'bg-blue-500/90', bgClass: "bg-blue-500/90",
icon: '️' icon: "️",
} },
}; };
return configs[type]; return configs[type];
}; };
// Create a toast element // Create a toast element
const createToastElement = (message: string, type: ToastType): HTMLElement => { const createToastElement = (message: string, type: ToastType): HTMLElement => {
const toast = document.createElement('div'); const toast = document.createElement("div");
const config = getToastConfig(type); const config = getToastConfig(type);
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`; toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
toast.innerHTML = ` toast.innerHTML = `
<span class="text-xl flex-shrink-0">${config.icon}</span> <span class="text-xl flex-shrink-0">${config.icon}</span>
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span> <span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity"> <button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
× ×
</button> </button>
`; `;
// Add close button handler // Add close button handler
const closeBtn = toast.querySelector('.toast-close') as HTMLElement; const closeBtn = toast.querySelector(".toast-close") as HTMLElement;
if (closeBtn) { if (closeBtn) {
closeBtn.onclick = () => removeToast(toast); closeBtn.onclick = () => removeToast(toast);
} }
return toast; return toast;
}; };
// Trigger toast animation // Trigger toast animation
const animateToastIn = (toast: HTMLElement): void => { const animateToastIn = (toast: HTMLElement): void => {
setTimeout(() => { setTimeout(() => {
toast.style.opacity = '1'; toast.style.opacity = "1";
toast.style.transform = 'translateY(0)'; toast.style.transform = "translateY(0)";
}, 10); }, 10);
}; };
// Remove toast with animation // Remove toast with animation
const removeToast = (toast: HTMLElement): void => { const removeToast = (toast: HTMLElement): void => {
toast.style.opacity = '0'; toast.style.opacity = "0";
toast.style.transform = 'translateY(-20px)'; toast.style.transform = "translateY(-20px)";
setTimeout(() => { setTimeout(() => {
if (toast.parentElement) { if (toast.parentElement) {
toast.parentElement.removeChild(toast); toast.parentElement.removeChild(toast);
} }
}, 300); }, 300);
}; };
// Show toast notification // Show toast notification
const showToast = (message: string, type: ToastType, duration: number = TOAST_DEFAULT_DURATION): void => { const showToast = (
const container = createToastContainer(); message: string,
const toast = createToastElement(message, type); type: ToastType,
container.appendChild(toast); duration: number = TOAST_DEFAULT_DURATION,
animateToastIn(toast); ): void => {
const container = createToastContainer();
// Auto-remove after duration const toast = createToastElement(message, type);
setTimeout(() => { container.appendChild(toast);
removeToast(toast); animateToastIn(toast);
}, duration);
// Auto-remove after duration
setTimeout(() => {
removeToast(toast);
}, duration);
}; };
// Parse error from XHR response // Parse error from XHR response
const parseXHRError = (xhr: XMLHttpRequest): string => { const parseXHRError = (xhr: XMLHttpRequest): string => {
let errorMessage = 'An error occurred'; let errorMessage = "An error occurred";
try { try {
const response = JSON.parse(xhr.responseText); const response = JSON.parse(xhr.responseText);
errorMessage = response.error || response.message || errorMessage; errorMessage = response.error || response.message || errorMessage;
} catch (e) { } catch (e) {
errorMessage = xhr.responseText || errorMessage; errorMessage = xhr.responseText || errorMessage;
} }
return errorMessage; return errorMessage;
}; };
// Parse error from fetch response // Parse error from fetch response
const parseFetchError = async (response: Response): Promise<string> => { const parseFetchError = async (response: Response): Promise<string> => {
const contentType = response.headers.get('content-type'); const contentType = response.headers.get("content-type");
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes("application/json")) {
const data = await response.json(); const data = await response.json();
return data.error || data.message || `Error ${response.status}`; return data.error || data.message || `Error ${response.status}`;
} }
return `Error ${response.status}: ${response.statusText}`; return `Error ${response.status}: ${response.statusText}`;
}; };
// Setup HTMX error listeners // Setup HTMX error listeners
const setupHTMXListeners = (): void => { const setupHTMXListeners = (): void => {
// Listen for HTMX afterSwap event to detect errors in swapped content // Listen for HTMX afterSwap event to detect errors in swapped content
document.body.addEventListener('htmx:afterSwap', (evt: Event) => { document.body.addEventListener("htmx:afterSwap", (evt: Event) => {
interface HTMXEventDetail { interface HTMXEventDetail {
xhr: XMLHttpRequest; xhr: XMLHttpRequest;
succeeded: boolean; succeeded: boolean;
target: Element; target: Element;
} }
const customEvent = evt as CustomEvent<HTMXEventDetail>; const customEvent = evt as CustomEvent<HTMXEventDetail>;
// Check if request failed // Check if request failed
if (customEvent.detail.succeeded === false && customEvent.detail.xhr) { if (customEvent.detail.succeeded === false && customEvent.detail.xhr) {
const xhr = customEvent.detail.xhr; const xhr = customEvent.detail.xhr;
// Show toast for HTTP errors // Show toast for HTTP errors
if (xhr.status >= 400 && xhr.status < 600) { if (xhr.status >= 400 && xhr.status < 600) {
const errorMessage = parseXHRError(xhr);
showToast(errorMessage, 'error');
}
}
});
// Also listen for response errors (network issues, invalid responses)
document.body.addEventListener('htmx:responseError', (evt: Event) => {
const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>;
const xhr = customEvent.detail.xhr;
const errorMessage = parseXHRError(xhr); const errorMessage = parseXHRError(xhr);
showToast(errorMessage, 'error'); showToast(errorMessage, "error");
}); }
}
});
// Also listen for response errors (network issues, invalid responses)
document.body.addEventListener("htmx:responseError", (evt: Event) => {
const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>;
const xhr = customEvent.detail.xhr;
const errorMessage = parseXHRError(xhr);
showToast(errorMessage, "error");
});
}; };
// Setup fetch interceptor // Setup fetch interceptor
const setupFetchInterceptor = (): void => { const setupFetchInterceptor = (): void => {
const originalFetch = window.fetch; const originalFetch = window.fetch;
window.fetch = async (...args: Parameters<typeof fetch>): Promise<Response> => { window.fetch = async (
try { ...args: Parameters<typeof fetch>
const response = await originalFetch(...args); ): Promise<Response> => {
try {
const response = await originalFetch(...args);
// Special handling for 401 Unauthorized // Special handling for 401 Unauthorized
if (response.status === 401) { if (response.status === 401) {
// Clear invalid tokens from localStorage // Clear invalid tokens from localStorage
localStorage.removeItem('token'); localStorage.removeItem("token");
localStorage.removeItem('refreshToken'); localStorage.removeItem("refreshToken");
localStorage.removeItem('user'); localStorage.removeItem("user");
// Check if this was a page navigation (not API call) // Check if this was a page navigation (not API call)
const url = args[0] as string; const url = args[0] as string;
// Don't show toast for page navigations - will be handled by redirect // Don't show toast for page navigations - will be handled by redirect
if (!url.startsWith('/api/')) { if (!url.startsWith("/api/")) {
// Direct navigation to protected page will be caught by middleware // Direct navigation to protected page will be caught by middleware
// Just throw to prevent further processing // Just throw to prevent further processing
throw new Error('Session expired'); throw new Error("Session expired");
}
// API call - show toast error
const errorMessage = await parseFetchError(response);
showToast(errorMessage, 'error');
return response;
}
// Handle other errors
if (!response.ok) {
const errorMessage = await parseFetchError(response);
showToast(errorMessage, 'error');
}
return response;
} catch (error) {
// Don't show toast for redirect errors
if ((error as Error).message !== 'Session expired') {
showToast('Network error: Unable to connect to server', 'error');
}
throw error;
} }
};
// API call - show toast error
const errorMessage = await parseFetchError(response);
showToast(errorMessage, "error");
return response;
}
// Handle other errors
if (!response.ok) {
const errorMessage = await parseFetchError(response);
showToast(errorMessage, "error");
}
return response;
} catch (error) {
// Don't show toast for redirect errors
if ((error as Error).message !== "Session expired") {
showToast("Network error: Unable to connect to server", "error");
}
throw error;
}
};
}; };
// Initialize toast system // Initialize toast system
const initializeToastSystem = (): void => { const initializeToastSystem = (): void => {
setupHTMXListeners(); setupHTMXListeners();
setupFetchInterceptor(); setupFetchInterceptor();
}; };
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', initializeToastSystem); document.addEventListener("DOMContentLoaded", initializeToastSystem);
} else { } else {
initializeToastSystem(); initializeToastSystem();
} }
} }
// Export toast API for manual use // Export toast API for manual use
(window as any).showToast = { (window as any).showToast = {
error: (message: string, duration?: number) => showToast(message, 'error', duration), error: (message: string, duration?: number) =>
success: (message: string, duration?: number) => showToast(message, 'success', duration), showToast(message, "error", duration),
info: (message: string, duration?: number) => showToast(message, 'info', duration) success: (message: string, duration?: number) =>
showToast(message, "success", duration),
info: (message: string, duration?: number) =>
showToast(message, "info", duration),
}; };
+200 -185
View File
@@ -11,75 +11,75 @@
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it // Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
// Used in: search.ts // Used in: search.ts
interface MediaItemSummary { interface MediaItemSummary {
id: string; id: string;
library_id: string; library_id: string;
title: string; title: string;
author?: string; author?: string;
isbn?: string; isbn?: string;
description?: string; description?: string;
file_path: string; file_path: string;
file_size?: number; file_size?: number;
mime_type?: string; mime_type?: string;
cover_image_path?: string; cover_image_path?: string;
series?: string; series?: string;
series_number?: number; series_number?: number;
tags?: string[]; tags?: string[];
asin?: string; asin?: string;
date_published?: string; date_published?: string;
publisher?: string; publisher?: string;
contributors?: string[]; contributors?: string[];
language?: string; language?: string;
edition?: string; edition?: string;
page_count?: number; page_count?: number;
genre?: string; genre?: string;
copyright_year?: number; copyright_year?: number;
goodreads_id?: string; goodreads_id?: string;
openlibrary_id?: string; openlibrary_id?: string;
google_books_id?: string; google_books_id?: string;
added_by_admin_id?: string; added_by_admin_id?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
format_group: string; format_group: string;
format_mimetype?: string; format_mimetype?: string;
is_reflowable?: boolean; is_reflowable?: boolean;
has_fixed_layout?: boolean; has_fixed_layout?: boolean;
total_characters?: number; total_characters?: number;
chapter_count?: number; chapter_count?: number;
entitlement_id?: string; entitlement_id?: string;
revision_number?: number; revision_number?: number;
kobo_content_id?: string; kobo_content_id?: string;
kobo_metadata?: string; kobo_metadata?: string;
tags_search?: string[]; tags_search?: string[];
contributors_search?: string[]; contributors_search?: string[];
file_sha256?: string; file_sha256?: string;
opf_identifier?: string; opf_identifier?: string;
opf_uuid?: string; opf_uuid?: string;
hash_confidence?: string; hash_confidence?: string;
library_name: string; library_name: string;
library_type_name: string; library_type_name: string;
} }
// Matches handlers.CollectionData / CollectionResponse JSON response // Matches handlers.CollectionData / CollectionResponse JSON response
// Source: internal/handlers/collections.go:123-131 CollectionResponse // Source: internal/handlers/collections.go:123-131 CollectionResponse
// Used in: collections.ts // Used in: collections.ts
interface CollectionData { interface CollectionData {
id: string; id: string;
name: string; name: string;
description: string; description: string;
color: string; color: string;
icon: string; icon: string;
auto_assign_rules?: unknown; auto_assign_rules?: unknown;
created_at: string; created_at: string;
} }
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71) // Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)
// JSON tags: media_item_id, title, author, cover_image_path // JSON tags: media_item_id, title, author, cover_image_path
// Used in: collections.templ (server-rendered), collections.ts // Used in: collections.templ (server-rendered), collections.ts
interface BookInfo { interface BookInfo {
media_item_id: string; media_item_id: string;
title: string; title: string;
author: string; author: string;
cover_image_path: string; cover_image_path: string;
} }
// Dashboard type definitions // Dashboard type definitions
@@ -87,237 +87,252 @@ interface BookInfo {
// Source: handlers.SectionData in collections.go (lines 73-81) // Source: handlers.SectionData in collections.go (lines 73-81)
// Used in: dashboard API responses, TypeScript dashboard components // Used in: dashboard API responses, TypeScript dashboard components
interface SectionData { interface SectionData {
id: string; id: string;
is_system: boolean; is_system: boolean;
title: string; title: string;
description: string; description: string;
icon: string; icon: string;
items: BookInfo[]; items: BookInfo[];
view_all_url: string; view_all_url: string;
priority: number; priority: number;
} }
// Matches database.UserDashboardPreferences and dashboard preferences API // Matches database.UserDashboardPreferences and dashboard preferences API
// Source: internal/database/models.go:381-390 // Source: internal/database/models.go:381-390
// Used in: dashboard preferences API // Used in: dashboard preferences API
interface DashboardPreferences { interface DashboardPreferences {
library_id: string; library_id: string;
hidden_collections: string[]; hidden_collections: string[];
collection_order: string[]; collection_order: string[];
items_per_section: number; items_per_section: number;
} }
// Matches handlers.UnlinkedBookData JSON response // Matches handlers.UnlinkedBookData JSON response
// Used in: unlinked_books.ts, unlinked_books.templ // Used in: unlinked_books.ts, unlinked_books.templ
interface UnlinkedBookData { interface UnlinkedBookData {
progress_id: string; progress_id: string;
device_id: string; device_id: string;
device_name: string; device_name: string;
device_type: 'koreader' | 'kobo' | 'web'; device_type: "koreader" | "kobo" | "web";
title_from_device: string; title_from_device: string;
file_path: string; file_path: string;
sha256: string; sha256: string;
last_sync_time: string; last_sync_time: string;
confidence_score: number; confidence_score: number;
potential_matches: PotentialMatchData[]; potential_matches: PotentialMatchData[];
} }
interface PotentialMatchData { interface PotentialMatchData {
media_item_id: string; media_item_id: string;
title: string; title: string;
author: string; author: string;
confidence: number; confidence: number;
cover_image_path?: string; cover_image_path?: string;
} }
// Matches collection rule objects // Matches collection rule objects
// Used in: collection_rules.ts // Used in: collection_rules.ts
interface CollectionRule { interface CollectionRule {
id: string; id: string;
field: 'genre' | 'series' | 'author' | 'language' | 'publisher' | 'copyright_year' | 'tags'; field:
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'greater_than' | 'less_than'; | "genre"
value: string; | "series"
enabled: boolean; | "author"
priority: number; | "language"
| "publisher"
| "copyright_year"
| "tags";
operator:
| "equals"
| "not_equals"
| "contains"
| "not_contains"
| "starts_with"
| "ends_with"
| "greater_than"
| "less_than";
value: string;
enabled: boolean;
priority: number;
} }
// Matches API test rule responses // Matches API test rule responses
// Used in: collection_rules.ts (test results) // Used in: collection_rules.ts (test results)
interface TestRuleMatch { interface TestRuleMatch {
title: string; title: string;
author: string; author: string;
cover_image_path?: string; cover_image_path?: string;
} }
// Matches handlers.SearchResponse (internal/handlers/search.go) // Matches handlers.SearchResponse (internal/handlers/search.go)
interface SearchResponse { interface SearchResponse {
results: SearchBookResponse[]; results: SearchBookResponse[];
total: number; total: number;
} }
interface SearchBookResponse { interface SearchBookResponse {
id: string; id: string;
title: string; title: string;
authors: SearchAuthor[]; authors: SearchAuthor[];
} }
interface SearchAuthor { interface SearchAuthor {
first_name: string; first_name: string;
last_name: string; last_name: string;
} }
// Matches AuthResponse (internal/handlers/auth.go:59-65) // Matches AuthResponse (internal/handlers/auth.go:59-65)
interface AuthResponse { interface AuthResponse {
access_token: string; access_token: string;
refresh_token?: string; refresh_token?: string;
token_type: string; token_type: string;
expires_in: number; expires_in: number;
user: UserProfile; user: UserProfile;
} }
interface UserProfile { interface UserProfile {
id: string; id: string;
email: string; email: string;
username: string; username: string;
first_name?: string; first_name?: string;
last_name?: string; last_name?: string;
role: string; role: string;
theme?: string; theme?: string;
} }
// Matches handlers.ReadingStatsResponse (internal/handlers/analytics.go:26-35) // Matches handlers.ReadingStatsResponse (internal/handlers/analytics.go:26-35)
// Used in: analytics.ts // Used in: analytics.ts
interface ReadingStatsResponse { interface ReadingStatsResponse {
total_books_read: number; total_books_read: number;
total_pages_read: number; total_pages_read: number;
total_reading_time_minutes: number; total_reading_time_minutes: number;
average_session_time_minutes: number; average_session_time_minutes: number;
longest_session_minutes: number; longest_session_minutes: number;
most_active_day_of_week: string; most_active_day_of_week: string;
completion_rate: number; completion_rate: number;
daily_reading_minutes: DailyReading[]; daily_reading_minutes: DailyReading[];
} }
interface DailyReading { interface DailyReading {
date: string; date: string;
minutes: number; minutes: number;
pages: number; pages: number;
} }
// Matches handlers.DeviceUsageResponse (internal/handlers/analytics.go:43-45) // Matches handlers.DeviceUsageResponse (internal/handlers/analytics.go:43-45)
// Note: Response is wrapped: { devices: DeviceUsage[] } // Note: Response is wrapped: { devices: DeviceUsage[] }
// Used in: analytics.ts // Used in: analytics.ts
interface DeviceUsageResponse { interface DeviceUsageResponse {
devices: DeviceUsage[]; devices: DeviceUsage[];
} }
interface DeviceUsage { interface DeviceUsage {
device_id: string; device_id: string;
device_name: string; device_name: string;
device_type: string; device_type: string;
sync_count: number; sync_count: number;
last_sync: string; last_sync: string;
total_time_seconds: number; total_time_seconds: number;
total_time_minutes: number; total_time_minutes: number;
} }
// Matches handlers.PopularBooksResponse (internal/handlers/analytics.go:57-59) // Matches handlers.PopularBooksResponse (internal/handlers/analytics.go:57-59)
// Note: Response is wrapped: { books: PopularBook[] } // Note: Response is wrapped: { books: PopularBook[] }
// Used in: analytics.ts // Used in: analytics.ts
interface PopularBooksResponse { interface PopularBooksResponse {
books: PopularBook[]; books: PopularBook[];
} }
interface PopularBook { interface PopularBook {
media_item_id: string; media_item_id: string;
title: string; title: string;
author: string; author: string;
read_count: number; read_count: number;
avg_completion: number; avg_completion: number;
last_read: string; last_read: string;
} }
// Matches handlers.QueueItemResponse (internal/handlers/queue.go:35-51) // Matches handlers.QueueItemResponse (internal/handlers/queue.go:35-51)
// Used in: queue.ts // Used in: queue.ts
interface QueueItemResponse { interface QueueItemResponse {
id: string; id: string;
device_id: string; device_id: string;
device_name: string; device_name: string;
device_type: string; device_type: string;
media_item_id?: string; media_item_id?: string;
media_title?: string; media_title?: string;
user_email: string; user_email: string;
sync_type: string; sync_type: string;
priority: number; priority: number;
attempts: number; attempts: number;
max_attempts: number; max_attempts: number;
status: string; status: string;
error_message?: string; error_message?: string;
created_at: string; created_at: string;
processed_at?: string; processed_at?: string;
} }
// Matches handlers.QueueStatsResponse (internal/handlers/queue.go:27-33) // Matches handlers.QueueStatsResponse (internal/handlers/queue.go:27-33)
// Used in: queue.ts // Used in: queue.ts
interface QueueStatsResponse { interface QueueStatsResponse {
pending_count: number; pending_count: number;
processing_count: number; processing_count: number;
failed_count: number; failed_count: number;
completed_count: number; completed_count: number;
total_count: number; total_count: number;
} }
// Matches handlers.ConflictDetailResponse (internal/handlers/conflicts.go:42-53) // Matches handlers.ConflictDetailResponse (internal/handlers/conflicts.go:42-53)
// Used in: conflicts.ts // Used in: conflicts.ts
interface ConflictDetailResponse { interface ConflictDetailResponse {
id: string; id: string;
media_item_id: string; media_item_id: string;
media_item_title: string; media_item_title: string;
conflict_type: string; conflict_type: string;
conflict_data: Record<string, ConflictSourceData>; conflict_data: Record<string, ConflictSourceData>;
resolution_status: string; resolution_status: string;
resolution_data?: Record<string, unknown>; resolution_data?: Record<string, unknown>;
resolved_by?: string; resolved_by?: string;
resolved_at?: string; resolved_at?: string;
created_at: string; created_at: string;
} }
// Matches handlers.ConflictSourceData (internal/handlers/conflicts.go:36-40) // Matches handlers.ConflictSourceData (internal/handlers/conflicts.go:36-40)
interface ConflictSourceData { interface ConflictSourceData {
source: string; source: string;
timestamp: string; timestamp: string;
data: Record<string, unknown>; data: Record<string, unknown>;
} }
// Matches handlers.ConflictListResponse (internal/handlers/conflicts.go:55-59) // Matches handlers.ConflictListResponse (internal/handlers/conflicts.go:55-59)
// Used in: conflicts.ts // Used in: conflicts.ts
interface ConflictListResponse { interface ConflictListResponse {
conflicts: ConflictDetailResponse[]; conflicts: ConflictDetailResponse[];
total: number; total: number;
unresolved: number; unresolved: number;
} }
// Matches handlers.ConflictResolveResponse (internal/handlers/conflicts.go:61-65) // Matches handlers.ConflictResolveResponse (internal/handlers/conflicts.go:61-65)
// Used in: conflicts.ts // Used in: conflicts.ts
interface ConflictResolveResponse { interface ConflictResolveResponse {
conflict_resolved: boolean; conflict_resolved: boolean;
applied_to: Record<string, boolean>; applied_to: Record<string, boolean>;
devices_synced: string[]; devices_synced: string[];
} }
// Matches handlers.BulkResolveResponse (internal/handlers/conflicts.go:424-429) // Matches handlers.BulkResolveResponse (internal/handlers/conflicts.go:424-429)
// Used in: conflicts.ts // Used in: conflicts.ts
interface BulkResolveResponse { interface BulkResolveResponse {
results: ConflictResult[]; results: ConflictResult[];
total: number; total: number;
success: number; success: number;
failed: number; failed: number;
} }
// Matches handlers.ConflictResult (internal/handlers/conflicts.go:431-436) // Matches handlers.ConflictResult (internal/handlers/conflicts.go:431-436)
interface ConflictResult { interface ConflictResult {
conflict_id: string; conflict_id: string;
status: string; status: string;
error?: string; error?: string;
winner?: string; winner?: string;
} }
+59 -53
View File
@@ -1,70 +1,76 @@
// Wood paneling management functionality // Wood paneling management functionality
type WoodPanelingType = 'none' | 'wood-light' | 'wood-dark' | 'wood-mahogany'; type WoodPanelingType = "none" | "wood-light" | "wood-dark" | "wood-mahogany";
const WOOD_STORAGE_KEY = 'wood-paneling'; const WOOD_STORAGE_KEY = "wood-paneling";
// Apply wood paneling to collections container // Apply wood paneling to collections container
const applyWoodPaneling = (paneling: WoodPanelingType): void => { const applyWoodPaneling = (paneling: WoodPanelingType): void => {
const container = document.getElementById('collections-container'); const container = document.getElementById("collections-container");
if (!container) return; if (!container) return;
// Remove all wood background classes // Remove all wood background classes
container.classList.remove('bg-wood-light', 'bg-wood-dark', 'bg-wood-mahogany'); container.classList.remove(
container.removeAttribute('data-wood'); "bg-wood-light",
"bg-wood-dark",
"bg-wood-mahogany",
);
container.removeAttribute("data-wood");
if (paneling !== 'none') { if (paneling !== "none") {
// Add selected wood background class // Add selected wood background class
container.classList.add(`bg-${paneling}`); container.classList.add(`bg-${paneling}`);
container.setAttribute('data-wood', paneling); container.setAttribute("data-wood", paneling);
} }
// Save to localStorage // Save to localStorage
localStorage.setItem(WOOD_STORAGE_KEY, paneling); localStorage.setItem(WOOD_STORAGE_KEY, paneling);
}; };
// Load wood paneling from localStorage on page load // Load wood paneling from localStorage on page load
const loadWoodPaneling = (): void => { const loadWoodPaneling = (): void => {
const stored = localStorage.getItem(WOOD_STORAGE_KEY) as WoodPanelingType | null; const stored = localStorage.getItem(
if (stored) { WOOD_STORAGE_KEY,
applyWoodPaneling(stored); ) as WoodPanelingType | null;
} else { if (stored) {
// Default to none applyWoodPaneling(stored);
applyWoodPaneling('none'); } else {
} // Default to none
applyWoodPaneling("none");
}
}; };
// Change wood paneling (called from theme dropdown) // Change wood paneling (called from theme dropdown)
const changeWoodPaneling = (paneling: WoodPanelingType): void => { const changeWoodPaneling = (paneling: WoodPanelingType): void => {
applyWoodPaneling(paneling); applyWoodPaneling(paneling);
// Update active indicators // Update active indicators
updateWoodPanelingIndicators(); updateWoodPanelingIndicators();
// Close dropdown // Close dropdown
const dropdown = document.getElementById('theme-dropdown'); const dropdown = document.getElementById("theme-dropdown");
if (dropdown) { if (dropdown) {
dropdown.classList.add('hidden'); dropdown.classList.add("hidden");
} }
}; };
// Update visual indicators for wood paneling buttons // Update visual indicators for wood paneling buttons
const updateWoodPanelingIndicators = (): void => { const updateWoodPanelingIndicators = (): void => {
const currentWood = localStorage.getItem(WOOD_STORAGE_KEY) || 'none'; const currentWood = localStorage.getItem(WOOD_STORAGE_KEY) || "none";
// Update wood paneling buttons // Update wood paneling buttons
document.querySelectorAll('.wood-paneling-btn').forEach(btn => { document.querySelectorAll(".wood-paneling-btn").forEach((btn) => {
const wood = btn.getAttribute('data-wood'); const wood = btn.getAttribute("data-wood");
if (wood === currentWood) { if (wood === currentWood) {
// Active state - use CSS class instead of inline style // Active state - use CSS class instead of inline style
btn.classList.add('bg-wood-active'); btn.classList.add("bg-wood-active");
btn.classList.remove('bg-wood-inactive'); btn.classList.remove("bg-wood-inactive");
} else { } else {
// Inactive state // Inactive state
btn.classList.remove('bg-wood-active'); btn.classList.remove("bg-wood-active");
btn.classList.add('bg-wood-inactive'); btn.classList.add("bg-wood-inactive");
} }
}); });
}; };
// Make functions available globally // Make functions available globally
@@ -73,14 +79,14 @@ const updateWoodPanelingIndicators = (): void => {
(window as any).updateWoodPanelingIndicators = updateWoodPanelingIndicators; (window as any).updateWoodPanelingIndicators = updateWoodPanelingIndicators;
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", () => {
loadWoodPaneling(); loadWoodPaneling();
updateWoodPanelingIndicators(); updateWoodPanelingIndicators();
}); });
} else { } else {
loadWoodPaneling(); loadWoodPaneling();
updateWoodPanelingIndicators(); updateWoodPanelingIndicators();
} }
} }
+17 -17
View File
@@ -1,25 +1,25 @@
// Early initialization script to prevent flash of wrong background // Early initialization script to prevent flash of wrong background
// Loads before woodPaneling.js to apply paneling immediately // Loads before woodPaneling.js to apply paneling immediately
const WOOD_INIT_STORAGE_KEY = 'wood-paneling'; const WOOD_INIT_STORAGE_KEY = "wood-paneling";
// Apply wood paneling immediately (before DOM ready if possible) // Apply wood paneling immediately (before DOM ready if possible)
(function() { (function () {
const woodPaneling = localStorage.getItem(WOOD_INIT_STORAGE_KEY) || 'none'; const woodPaneling = localStorage.getItem(WOOD_INIT_STORAGE_KEY) || "none";
if (woodPaneling !== 'none') { if (woodPaneling !== "none") {
const applyPaneling = () => { const applyPaneling = () => {
const container = document.getElementById('collections-container'); const container = document.getElementById("collections-container");
if (container) { if (container) {
container.classList.add(`bg-${woodPaneling}`); container.classList.add(`bg-${woodPaneling}`);
container.setAttribute('data-wood', woodPaneling); container.setAttribute("data-wood", woodPaneling);
} }
}; };
// Apply immediately if DOM is ready, otherwise wait // Apply immediately if DOM is ready, otherwise wait
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', applyPaneling); document.addEventListener("DOMContentLoaded", applyPaneling);
} else { } else {
applyPaneling(); applyPaneling();
}
} }
}
})(); })();