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