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:
+110
-73
@@ -1,66 +1,68 @@
|
|||||||
async function triggerLibraryScan(): Promise<void> {
|
async function triggerLibraryScan(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/libraries/scan', {
|
const response = await fetch("/api/libraries/scan", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Library scan started');
|
(window as any).showToast.success("Library scan started");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error(error.error || 'Failed to start scan');
|
(window as any).showToast.error(error.error || "Failed to start scan");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Scan error:', error);
|
console.error("Scan error:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to start library scan');
|
(window as any).showToast.error("Failed to start library scan");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerQuickScan(): Promise<void> {
|
async function triggerQuickScan(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/libraries/quick-scan', {
|
const response = await fetch("/api/libraries/quick-scan", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Quick scan started');
|
(window as any).showToast.success("Quick scan started");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error(error.error || 'Failed to start quick scan');
|
(window as any).showToast.error(
|
||||||
|
error.error || "Failed to start quick scan",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Quick scan error:', error);
|
console.error("Quick scan error:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to start quick scan');
|
(window as any).showToast.error("Failed to start quick scan");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSystemStats(): Promise<void> {
|
async function loadSystemStats(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/stats', {
|
const response = await fetch("/api/admin/stats", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -68,12 +70,12 @@ async function loadSystemStats(): Promise<void> {
|
|||||||
renderSystemStats(stats);
|
renderSystemStats(stats);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load stats:', error);
|
console.error("Failed to load stats:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSystemStats(stats: Record<string, unknown>): void {
|
function renderSystemStats(stats: Record<string, unknown>): void {
|
||||||
const container = document.getElementById('system-stats');
|
const container = document.getElementById("system-stats");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
@@ -99,23 +101,25 @@ function renderSystemStats(stats: Record<string, unknown>): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function scanAllLibraries(): Promise<void> {
|
async function scanAllLibraries(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const libsResp = await fetch('/api/libraries', {
|
const libsResp = await fetch("/api/libraries", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!libsResp.ok) {
|
if (!libsResp.ok) {
|
||||||
throw new Error('Failed to get libraries');
|
throw new Error("Failed to get libraries");
|
||||||
}
|
}
|
||||||
|
|
||||||
const libsData = await libsResp.json();
|
const libsData = await libsResp.json();
|
||||||
|
|
||||||
if (!libsData.data || libsData.data.length === 0) {
|
if (!libsData.data || libsData.data.length === 0) {
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('No libraries found. Please create a library first.');
|
(window as any).showToast.error(
|
||||||
|
"No libraries found. Please create a library first.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -127,12 +131,12 @@ async function scanAllLibraries(): Promise<void> {
|
|||||||
|
|
||||||
for (const lib of libraries) {
|
for (const lib of libraries) {
|
||||||
const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, {
|
const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ force: true })
|
body: JSON.stringify({ force: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (scanResp.ok) {
|
if (scanResp.ok) {
|
||||||
@@ -146,31 +150,39 @@ async function scanAllLibraries(): Promise<void> {
|
|||||||
|
|
||||||
if (jobs.length === 0) {
|
if (jobs.length === 0) {
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to start scan for any library');
|
(window as any).showToast.error("Failed to start scan for any library");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showScanProgress(jobs, libraryNames);
|
showScanProgress(jobs, libraryNames);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Scan error:', error);
|
console.error("Scan error:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to start scan: ' + (error as Error).message);
|
(window as any).showToast.error(
|
||||||
|
"Failed to start scan: " + (error as Error).message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showScanProgress(jobIds: string[], libraryNames: Record<string, string>): void {
|
function showScanProgress(
|
||||||
const container = document.getElementById('scan-progress-container') as HTMLElement;
|
jobIds: string[],
|
||||||
const list = document.getElementById('library-progress-list') as HTMLElement;
|
libraryNames: Record<string, string>,
|
||||||
|
): void {
|
||||||
|
const container = document.getElementById(
|
||||||
|
"scan-progress-container",
|
||||||
|
) as HTMLElement;
|
||||||
|
const list = document.getElementById("library-progress-list") as HTMLElement;
|
||||||
|
|
||||||
if (!container || !list) return;
|
if (!container || !list) return;
|
||||||
|
|
||||||
container.classList.remove('hidden');
|
container.classList.remove("hidden");
|
||||||
container.classList.remove('opacity-0', '-translate-y-2.5');
|
container.classList.remove("opacity-0", "-translate-y-2.5");
|
||||||
|
|
||||||
list.innerHTML = jobIds.map(jobId => `
|
list.innerHTML = jobIds
|
||||||
|
.map(
|
||||||
|
(jobId) => `
|
||||||
<div id="progress-${jobId}" class="p-3 rounded border"
|
<div id="progress-${jobId}" class="p-3 rounded border"
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border);">
|
style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="flex justify-between items-center mb-2">
|
||||||
@@ -188,13 +200,18 @@ function showScanProgress(jobIds: string[], libraryNames: Record<string, string>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
pollScanProgress(jobIds, libraryNames);
|
pollScanProgress(jobIds, libraryNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string>): void {
|
function pollScanProgress(
|
||||||
const token = localStorage.getItem('token');
|
jobIds: string[],
|
||||||
|
_libraryNames: Record<string, string>,
|
||||||
|
): void {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
@@ -207,7 +224,7 @@ function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string
|
|||||||
for (const jobId of jobIds) {
|
for (const jobId of jobIds) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/scanner/status/${jobId}`, {
|
const resp = await fetch(`/api/scanner/status/${jobId}`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
@@ -220,7 +237,7 @@ function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string
|
|||||||
totalNewItems += status.new_items || 0;
|
totalNewItems += status.new_items || 0;
|
||||||
totalErrors += status.errors || 0;
|
totalErrors += status.errors || 0;
|
||||||
|
|
||||||
if (status.status !== 'completed' && status.status !== 'failed') {
|
if (status.status !== "completed" && status.status !== "failed") {
|
||||||
allComplete = false;
|
allComplete = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,12 +247,16 @@ function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overallProgress = Math.round(totalProgress / jobIds.length);
|
const overallProgress = Math.round(totalProgress / jobIds.length);
|
||||||
const progressBar = document.getElementById('scan-progress-bar') as HTMLElement;
|
const progressBar = document.getElementById(
|
||||||
const progressText = document.getElementById('scan-progress-text') as HTMLElement;
|
"scan-progress-bar",
|
||||||
const statusText = document.getElementById('scan-status') as HTMLElement;
|
) as HTMLElement;
|
||||||
|
const progressText = document.getElementById(
|
||||||
|
"scan-progress-text",
|
||||||
|
) as HTMLElement;
|
||||||
|
const statusText = document.getElementById("scan-status") as HTMLElement;
|
||||||
|
|
||||||
if (progressBar) progressBar.style.width = overallProgress + '%';
|
if (progressBar) progressBar.style.width = overallProgress + "%";
|
||||||
if (progressText) progressText.textContent = overallProgress + '%';
|
if (progressText) progressText.textContent = overallProgress + "%";
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||||
if (!allComplete && statusText) {
|
if (!allComplete && statusText) {
|
||||||
@@ -244,7 +265,13 @@ function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string
|
|||||||
|
|
||||||
if (allComplete) {
|
if (allComplete) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
showScanResults(jobIds.length, totalFiles, totalNewItems, totalErrors, elapsed);
|
showScanResults(
|
||||||
|
jobIds.length,
|
||||||
|
totalFiles,
|
||||||
|
totalNewItems,
|
||||||
|
totalErrors,
|
||||||
|
elapsed,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
@@ -254,66 +281,76 @@ function updateLibraryProgress(jobId: string, status: any): void {
|
|||||||
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
|
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
|
||||||
|
|
||||||
if (bar) {
|
if (bar) {
|
||||||
bar.style.width = (status.progress || 0) + '%';
|
bar.style.width = (status.progress || 0) + "%";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusText) {
|
if (statusText) {
|
||||||
const statusMessages: Record<string, string> = {
|
const statusMessages: Record<string, string> = {
|
||||||
'pending': 'Pending...',
|
pending: "Pending...",
|
||||||
'running': `Scanning... ${status.progress || 0}%`,
|
running: `Scanning... ${status.progress || 0}%`,
|
||||||
'completed': `✓ Complete (${status.new_items || 0} items)`,
|
completed: `✓ Complete (${status.new_items || 0} items)`,
|
||||||
'failed': `✗ Failed`
|
failed: `✗ Failed`,
|
||||||
};
|
};
|
||||||
statusText.textContent = statusMessages[status.status] || status.status;
|
statusText.textContent = statusMessages[status.status] || status.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showScanResults(libCount: number, files: number, items: number, errors: number, elapsed: number): void {
|
function showScanResults(
|
||||||
const resultsDiv = document.getElementById('scan-results') as HTMLElement;
|
libCount: number,
|
||||||
const contentDiv = document.getElementById('scan-results-content') as HTMLElement;
|
files: number,
|
||||||
|
items: number,
|
||||||
|
errors: number,
|
||||||
|
elapsed: number,
|
||||||
|
): void {
|
||||||
|
const resultsDiv = document.getElementById("scan-results") as HTMLElement;
|
||||||
|
const contentDiv = document.getElementById(
|
||||||
|
"scan-results-content",
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
if (!resultsDiv || !contentDiv) return;
|
if (!resultsDiv || !contentDiv) return;
|
||||||
|
|
||||||
contentDiv.innerHTML = `
|
contentDiv.innerHTML = `
|
||||||
<p>• ${libCount} librar${libCount === 1 ? 'y' : 'ies'} scanned</p>
|
<p>• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned</p>
|
||||||
<p>• ${files} files processed</p>
|
<p>• ${files} files processed</p>
|
||||||
<p>• ${items} new items added</p>
|
<p>• ${items} new items added</p>
|
||||||
${errors > 0 ? `<p style="color: var(--accent);">• ${errors} errors</p>` : ''}
|
${errors > 0 ? `<p style="color: var(--accent);">• ${errors} errors</p>` : ""}
|
||||||
<p style="color: var(--text-secondary)">Completed in ${elapsed} seconds</p>
|
<p style="color: var(--text-secondary)">Completed in ${elapsed} seconds</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
resultsDiv.classList.remove('hidden');
|
resultsDiv.classList.remove("hidden");
|
||||||
|
|
||||||
const statusText = document.getElementById('scan-status') as HTMLElement;
|
const statusText = document.getElementById("scan-status") as HTMLElement;
|
||||||
if (statusText) statusText.textContent = 'Scan complete!';
|
if (statusText) statusText.textContent = "Scan complete!";
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideScanProgress(): void {
|
function hideScanProgress(): void {
|
||||||
const container = document.getElementById('scan-progress-container') as HTMLElement;
|
const container = document.getElementById(
|
||||||
if (container) container.classList.add('hidden');
|
"scan-progress-container",
|
||||||
|
) as HTMLElement;
|
||||||
|
if (container) container.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWatchStatus(): Promise<void> {
|
async function loadWatchStatus(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/scanner/watch/status', {
|
const response = await fetch("/api/scanner/watch/status", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const countEl = document.getElementById('watch-count');
|
const countEl = document.getElementById("watch-count");
|
||||||
if (countEl) {
|
if (countEl) {
|
||||||
countEl.textContent = data.total_watching?.toString() || '0';
|
countEl.textContent = data.total_watching?.toString() || "0";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load watch status:', error);
|
console.error("Failed to load watch status:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
loadWatchStatus();
|
loadWatchStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+30
-20
@@ -1,18 +1,18 @@
|
|||||||
async function loadAnalytics(): Promise<void> {
|
async function loadAnalytics(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [statsRes, devicesRes, popularRes] = await Promise.all([
|
const [statsRes, devicesRes, popularRes] = await Promise.all([
|
||||||
fetch('/api/analytics/stats', {
|
fetch("/api/analytics/stats", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}),
|
}),
|
||||||
fetch('/api/analytics/devices', {
|
fetch("/api/analytics/devices", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
fetch("/api/analytics/popular", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}),
|
}),
|
||||||
fetch('/api/analytics/popular', {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statsRes.ok) {
|
if (statsRes.ok) {
|
||||||
@@ -30,15 +30,15 @@ async function loadAnalytics(): Promise<void> {
|
|||||||
renderPopularBooks(popular);
|
renderPopularBooks(popular);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load analytics:', error);
|
console.error("Failed to load analytics:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to load analytics data');
|
(window as any).showToast.error("Failed to load analytics data");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReadingStats(stats: ReadingStatsResponse): void {
|
function renderReadingStats(stats: ReadingStatsResponse): void {
|
||||||
const container = document.getElementById('reading-stats');
|
const container = document.getElementById("reading-stats");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
@@ -64,15 +64,18 @@ function renderReadingStats(stats: ReadingStatsResponse): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDeviceUsage(devices: DeviceUsageResponse): void {
|
function renderDeviceUsage(devices: DeviceUsageResponse): void {
|
||||||
const container = document.getElementById('device-usage');
|
const container = document.getElementById("device-usage");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (!devices.devices || devices.devices.length === 0) {
|
if (!devices.devices || devices.devices.length === 0) {
|
||||||
container.innerHTML = '<p style="color: var(--text-secondary)">No device usage data available</p>';
|
container.innerHTML =
|
||||||
|
'<p style="color: var(--text-secondary)">No device usage data available</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = devices.devices.map(device => `
|
container.innerHTML = devices.devices
|
||||||
|
.map(
|
||||||
|
(device) => `
|
||||||
<div class="p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
<div class="p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -85,19 +88,24 @@ function renderDeviceUsage(devices: DeviceUsageResponse): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPopularBooks(popular: PopularBooksResponse): void {
|
function renderPopularBooks(popular: PopularBooksResponse): void {
|
||||||
const container = document.getElementById('popular-books');
|
const container = document.getElementById("popular-books");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (!popular.books || popular.books.length === 0) {
|
if (!popular.books || popular.books.length === 0) {
|
||||||
container.innerHTML = '<p style="color: var(--text-secondary)">No reading history available</p>';
|
container.innerHTML =
|
||||||
|
'<p style="color: var(--text-secondary)">No reading history available</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = popular.books.map(book => `
|
container.innerHTML = popular.books
|
||||||
|
.map(
|
||||||
|
(book) => `
|
||||||
<div class="p-3 rounded-lg border flex items-center space-x-3" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
<div class="p-3 rounded-lg border flex items-center space-x-3" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="font-medium" style="color: var(--text-primary)">${book.title}</p>
|
<p class="font-medium" style="color: var(--text-primary)">${book.title}</p>
|
||||||
@@ -108,9 +116,11 @@ function renderPopularBooks(popular: PopularBooksResponse): void {
|
|||||||
<p class="text-sm" style="color: var(--text-secondary)">${Math.round(book.avg_completion * 100)}%</p>
|
<p class="text-sm" style="color: var(--text-secondary)">${Math.round(book.avg_completion * 100)}%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadAnalytics);
|
document.addEventListener("DOMContentLoaded", loadAnalytics);
|
||||||
|
|
||||||
(window as any).loadAnalytics = loadAnalytics;
|
(window as any).loadAnalytics = loadAnalytics;
|
||||||
|
|||||||
+46
-28
@@ -8,25 +8,29 @@ interface ApiExplorerRequest {
|
|||||||
const requestHistory: ApiExplorerRequest[] = [];
|
const requestHistory: ApiExplorerRequest[] = [];
|
||||||
|
|
||||||
function sendApiRequest(): void {
|
function sendApiRequest(): void {
|
||||||
const method = (document.getElementById('api-method') as HTMLSelectElement)?.value || 'GET';
|
const method =
|
||||||
const endpoint = (document.getElementById('api-endpoint') as HTMLInputElement)?.value || '';
|
(document.getElementById("api-method") as HTMLSelectElement)?.value ||
|
||||||
const bodyText = (document.getElementById('api-body') as HTMLTextAreaElement)?.value || '';
|
"GET";
|
||||||
|
const endpoint =
|
||||||
|
(document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
|
||||||
|
const bodyText =
|
||||||
|
(document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: ApiExplorerRequest = {
|
const request: ApiExplorerRequest = {
|
||||||
method,
|
method,
|
||||||
endpoint,
|
endpoint,
|
||||||
headers,
|
headers,
|
||||||
body: bodyText || undefined
|
body: bodyText || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
addToHistory(request);
|
addToHistory(request);
|
||||||
@@ -36,9 +40,9 @@ function sendApiRequest(): void {
|
|||||||
fetch(endpoint, {
|
fetch(endpoint, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: bodyText || undefined
|
body: bodyText || undefined,
|
||||||
})
|
})
|
||||||
.then(async response => {
|
.then(async (response) => {
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const duration = Math.round(endTime - startTime);
|
const duration = Math.round(endTime - startTime);
|
||||||
|
|
||||||
@@ -53,16 +57,20 @@ function sendApiRequest(): void {
|
|||||||
displayResponse(response, responseData, duration);
|
displayResponse(response, responseData, duration);
|
||||||
generateCurl(request);
|
generateCurl(request);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
displayError(error);
|
displayError(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayResponse(response: Response, data: unknown, duration: number): void {
|
function displayResponse(
|
||||||
const container = document.getElementById('api-response');
|
response: Response,
|
||||||
|
data: unknown,
|
||||||
|
duration: number,
|
||||||
|
): void {
|
||||||
|
const container = document.getElementById("api-response");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const statusColor = response.ok ? 'var(--accent)' : 'var(--error)';
|
const statusColor = response.ok ? "var(--accent)" : "var(--error)";
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -76,7 +84,7 @@ function displayResponse(response: Response, data: unknown, duration: number): v
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayError(error: Error): void {
|
function displayError(error: Error): void {
|
||||||
const container = document.getElementById('api-response');
|
const container = document.getElementById("api-response");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
@@ -87,7 +95,7 @@ function displayError(error: Error): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateCurl(request: ApiExplorerRequest): void {
|
function generateCurl(request: ApiExplorerRequest): void {
|
||||||
const container = document.getElementById('curl-command');
|
const container = document.getElementById("curl-command");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
let curl = `curl -X ${request.method} '${request.endpoint}'`;
|
let curl = `curl -X ${request.method} '${request.endpoint}'`;
|
||||||
@@ -112,49 +120,59 @@ function addToHistory(request: ApiExplorerRequest): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderHistory(): void {
|
function renderHistory(): void {
|
||||||
const container = document.getElementById('request-history');
|
const container = document.getElementById("request-history");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (requestHistory.length === 0) {
|
if (requestHistory.length === 0) {
|
||||||
container.innerHTML = '<p class="text-sm p-2" style="color: var(--text-secondary)">No requests yet</p>';
|
container.innerHTML =
|
||||||
|
'<p class="text-sm p-2" style="color: var(--text-secondary)">No requests yet</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = requestHistory.slice(0, 10).map((req, i) => `
|
container.innerHTML = requestHistory
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(
|
||||||
|
(req, i) => `
|
||||||
<div class="p-2 rounded cursor-pointer hover:bg-opacity-50 transition-colors"
|
<div class="p-2 rounded cursor-pointer hover:bg-opacity-50 transition-colors"
|
||||||
style="background-color: var(--bg-secondary)"
|
style="background-color: var(--bg-secondary)"
|
||||||
onclick="window.loadFromHistory(${i})">
|
onclick="window.loadFromHistory(${i})">
|
||||||
<span class="text-xs font-mono" style="color: ${req.method === 'GET' ? 'var(--accent)' : req.method === 'POST' ? 'var(--success)' : req.method === 'DELETE' ? 'var(--error)' : 'var(--text-primary)'}">${req.method}</span>
|
<span class="text-xs font-mono" style="color: ${req.method === "GET" ? "var(--accent)" : req.method === "POST" ? "var(--success)" : req.method === "DELETE" ? "var(--error)" : "var(--text-primary)"}">${req.method}</span>
|
||||||
<span class="text-xs ml-2" style="color: var(--text-secondary)">${req.endpoint}</span>
|
<span class="text-xs ml-2" style="color: var(--text-secondary)">${req.endpoint}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromHistory(index: number): void {
|
function loadFromHistory(index: number): void {
|
||||||
const request = requestHistory[index];
|
const request = requestHistory[index];
|
||||||
if (!request) return;
|
if (!request) return;
|
||||||
|
|
||||||
const methodSelect = document.getElementById('api-method') as HTMLSelectElement;
|
const methodSelect = document.getElementById(
|
||||||
const endpointInput = document.getElementById('api-endpoint') as HTMLInputElement;
|
"api-method",
|
||||||
const bodyInput = document.getElementById('api-body') as HTMLTextAreaElement;
|
) as HTMLSelectElement;
|
||||||
|
const endpointInput = document.getElementById(
|
||||||
|
"api-endpoint",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
|
||||||
|
|
||||||
if (methodSelect) methodSelect.value = request.method;
|
if (methodSelect) methodSelect.value = request.method;
|
||||||
if (endpointInput) endpointInput.value = request.endpoint;
|
if (endpointInput) endpointInput.value = request.endpoint;
|
||||||
if (bodyInput) bodyInput.value = request.body || '';
|
if (bodyInput) bodyInput.value = request.body || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyCurl(): void {
|
function copyCurl(): void {
|
||||||
const curl = document.getElementById('curl-command')?.textContent;
|
const curl = document.getElementById("curl-command")?.textContent;
|
||||||
if (curl) {
|
if (curl) {
|
||||||
navigator.clipboard.writeText(curl);
|
navigator.clipboard.writeText(curl);
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('cURL copied to clipboard');
|
(window as any).showToast.success("cURL copied to clipboard");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatJson(): void {
|
function formatJson(): void {
|
||||||
const bodyInput = document.getElementById('api-body') as HTMLTextAreaElement;
|
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
|
||||||
if (!bodyInput) return;
|
if (!bodyInput) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -162,7 +180,7 @@ function formatJson(): void {
|
|||||||
bodyInput.value = JSON.stringify(parsed, null, 2);
|
bodyInput.value = JSON.stringify(parsed, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Invalid JSON');
|
(window as any).showToast.error("Invalid JSON");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-26
@@ -1,64 +1,69 @@
|
|||||||
function getAuthHeader(): string {
|
function getAuthHeader(): string {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
return token ? `Bearer ${token}` : '';
|
return token ? `Bearer ${token}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiGet(url: string): Promise<Response> {
|
async function apiGet(url: string): Promise<Response> {
|
||||||
return fetch(`/api${url}`, {
|
return fetch(`/api${url}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': getAuthHeader(),
|
Authorization: getAuthHeader(),
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPost(url: string, data?: unknown): Promise<Response> {
|
async function apiPost(url: string, data?: unknown): Promise<Response> {
|
||||||
return fetch(`/api${url}`, {
|
return fetch(`/api${url}`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': getAuthHeader(),
|
Authorization: getAuthHeader(),
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: data ? JSON.stringify(data) : undefined
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPut(url: string, data?: unknown): Promise<Response> {
|
async function apiPut(url: string, data?: unknown): Promise<Response> {
|
||||||
return fetch(`/api${url}`, {
|
return fetch(`/api${url}`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': getAuthHeader(),
|
Authorization: getAuthHeader(),
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: data ? JSON.stringify(data) : undefined
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiDelete<T extends object>(url: string, data?: T): Promise<Response> {
|
async function apiDelete<T extends object>(
|
||||||
|
url: string,
|
||||||
|
data?: T,
|
||||||
|
): Promise<Response> {
|
||||||
return fetch(`/api${url}`, {
|
return fetch(`/api${url}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': getAuthHeader(),
|
Authorization: getAuthHeader(),
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: data ? JSON.stringify(data) : undefined
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiPatch(url: string, data?: unknown): Promise<Response> {
|
async function apiPatch(url: string, data?: unknown): Promise<Response> {
|
||||||
return fetch(`/api${url}`, {
|
return fetch(`/api${url}`, {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': getAuthHeader(),
|
Authorization: getAuthHeader(),
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: data ? JSON.stringify(data) : undefined
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResponse<T>(response: Response): Promise<T> {
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Unknown error" }));
|
||||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -66,14 +71,17 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
|||||||
|
|
||||||
async function handleVoidResponse(response: Response): Promise<void> {
|
async function handleVoidResponse(response: Response): Promise<void> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Unknown error" }));
|
||||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleError(error: unknown, context: string): void {
|
function handleError(error: unknown, context: string): void {
|
||||||
console.error(`${context}:`, error);
|
console.error(`${context}:`, error);
|
||||||
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
|
const message =
|
||||||
|
error instanceof Error ? error.message : "An unexpected error occurred";
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error(message);
|
(window as any).showToast.error(message);
|
||||||
}
|
}
|
||||||
@@ -87,5 +95,5 @@ function handleError(error: unknown, context: string): void {
|
|||||||
patch: apiPatch,
|
patch: apiPatch,
|
||||||
handleResponse,
|
handleResponse,
|
||||||
handleVoidResponse,
|
handleVoidResponse,
|
||||||
handleError
|
handleError,
|
||||||
};
|
};
|
||||||
|
|||||||
+72
-49
@@ -1,10 +1,10 @@
|
|||||||
async function loadCollections(): Promise<void> {
|
async function loadCollections(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/collections', {
|
const response = await fetch("/api/collections", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -12,40 +12,45 @@ async function loadCollections(): Promise<void> {
|
|||||||
renderCollections(data.collections || []);
|
renderCollections(data.collections || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load collections:', error);
|
console.error("Failed to load collections:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCollections(collections: CollectionData[]): void {
|
function renderCollections(collections: CollectionData[]): void {
|
||||||
const container = document.getElementById('collections-list');
|
const container = document.getElementById("collections-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (collections.length === 0) {
|
if (collections.length === 0) {
|
||||||
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No collections yet</p>';
|
container.innerHTML =
|
||||||
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No collections yet</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = collections.map(collection => `
|
container.innerHTML = collections
|
||||||
|
.map(
|
||||||
|
(collection) => `
|
||||||
<a href="/collections/${collection.id}" class="block p-4 rounded-lg border transition-colors hover:border-opacity-50"
|
<a href="/collections/${collection.id}" class="block p-4 rounded-lg border transition-colors hover:border-opacity-50"
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<span class="text-2xl">${collection.icon || '📁'}</span>
|
<span class="text-2xl">${collection.icon || "📁"}</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-medium" style="color: var(--text-primary)">${collection.name}</h3>
|
<h3 class="font-medium" style="color: var(--text-primary)">${collection.name}</h3>
|
||||||
${collection.description ? `<p class="text-sm" style="color: var(--text-secondary)">${collection.description}</p>` : ''}
|
${collection.description ? `<p class="text-sm" style="color: var(--text-secondary)">${collection.description}</p>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCollectionRules(collectionId: string): Promise<void> {
|
async function loadCollectionRules(collectionId: string): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -53,125 +58,143 @@ async function loadCollectionRules(collectionId: string): Promise<void> {
|
|||||||
renderRules(rules);
|
renderRules(rules);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load rules:', error);
|
console.error("Failed to load rules:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRules(rules: CollectionRule[]): void {
|
function renderRules(rules: CollectionRule[]): void {
|
||||||
const container = document.getElementById('rules-list');
|
const container = document.getElementById("rules-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (rules.length === 0) {
|
if (rules.length === 0) {
|
||||||
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
|
container.innerHTML =
|
||||||
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = rules.map(rule => `
|
container.innerHTML = rules
|
||||||
|
.map(
|
||||||
|
(rule) => `
|
||||||
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
|
<div class="p-3 rounded-lg border mb-2 flex justify-between items-center"
|
||||||
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
|
<p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p>
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? 'Enabled' : 'Disabled'}</p>
|
<p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
|
<button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button>
|
||||||
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
<button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
|
async function createRule(
|
||||||
const token = localStorage.getItem('token');
|
collectionId: string,
|
||||||
|
rule: Partial<CollectionRule>,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
const response = await fetch(`/api/collections/${collectionId}/rules`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(rule)
|
body: JSON.stringify(rule),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Rule created');
|
(window as any).showToast.success("Rule created");
|
||||||
}
|
}
|
||||||
loadCollectionRules(collectionId);
|
loadCollectionRules(collectionId);
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error(error.error || 'Failed to create rule');
|
(window as any).showToast.error(error.error || "Failed to create rule");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create rule:', error);
|
console.error("Failed to create rule:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to create rule');
|
(window as any).showToast.error("Failed to create rule");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
|
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
if (!confirm('Are you sure you want to delete this rule?')) return;
|
if (!confirm("Are you sure you want to delete this rule?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
|
const response = await fetch(
|
||||||
method: 'DELETE',
|
`/api/collections/${collectionId}/rules/${ruleId}`,
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
{
|
||||||
});
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Rule deleted');
|
(window as any).showToast.success("Rule deleted");
|
||||||
}
|
}
|
||||||
loadCollectionRules(collectionId);
|
loadCollectionRules(collectionId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete rule:', error);
|
console.error("Failed to delete rule:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to delete rule');
|
(window as any).showToast.error("Failed to delete rule");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
|
async function testRule(
|
||||||
const token = localStorage.getItem('token');
|
collectionId: string,
|
||||||
|
rule: Partial<CollectionRule>,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${collectionId}/rules/test`, {
|
const response = await fetch(
|
||||||
method: 'POST',
|
`/api/collections/${collectionId}/rules/test`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(rule)
|
body: JSON.stringify(rule),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const results = await response.json();
|
const results = await response.json();
|
||||||
renderTestResults(results);
|
renderTestResults(results);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to test rule:', error);
|
console.error("Failed to test rule:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to test rule');
|
(window as any).showToast.error("Failed to test rule");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTestResults(results: unknown[]): void {
|
function renderTestResults(results: unknown[]): void {
|
||||||
const container = document.getElementById('test-results');
|
const container = document.getElementById("test-results");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (!results || (Array.isArray(results) && results.length === 0)) {
|
if (!results || (Array.isArray(results) && results.length === 0)) {
|
||||||
container.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
|
container.innerHTML =
|
||||||
|
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-55
@@ -1,10 +1,10 @@
|
|||||||
async function refreshConflicts(): Promise<void> {
|
async function refreshConflicts(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/conflicts', {
|
const response = await fetch("/api/conflicts", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -13,55 +13,64 @@ async function refreshConflicts(): Promise<void> {
|
|||||||
updateConflictStats(data);
|
updateConflictStats(data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh conflicts:', error);
|
console.error("Failed to refresh conflicts:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveConflict(conflictId: string, winner: string, manualData?: Record<string, unknown>): Promise<void> {
|
async function resolveConflict(
|
||||||
const token = localStorage.getItem('token');
|
conflictId: string,
|
||||||
|
winner: string,
|
||||||
|
manualData?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/conflicts/${conflictId}/resolve`, {
|
const response = await fetch(`/api/conflicts/${conflictId}/resolve`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ winner, manual_data: manualData })
|
body: JSON.stringify({ winner, manual_data: manualData }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Conflict resolved');
|
(window as any).showToast.success("Conflict resolved");
|
||||||
}
|
}
|
||||||
refreshConflicts();
|
refreshConflicts();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error(error.error || 'Failed to resolve conflict');
|
(window as any).showToast.error(
|
||||||
|
error.error || "Failed to resolve conflict",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to resolve conflict:', error);
|
console.error("Failed to resolve conflict:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to resolve conflict');
|
(window as any).showToast.error("Failed to resolve conflict");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bulkResolve(strategy: 'most_recent' | 'highest_progress', conflictIds: string[]): Promise<void> {
|
async function bulkResolve(
|
||||||
const token = localStorage.getItem('token');
|
strategy: "most_recent" | "highest_progress",
|
||||||
|
conflictIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/conflicts/bulk-resolve', {
|
const response = await fetch("/api/conflicts/bulk-resolve", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ conflict_ids: conflictIds, strategy })
|
body: JSON.stringify({ conflict_ids: conflictIds, strategy }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -72,113 +81,124 @@ async function bulkResolve(strategy: 'most_recent' | 'highest_progress', conflic
|
|||||||
refreshConflicts();
|
refreshConflicts();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to bulk resolve:', error);
|
console.error("Failed to bulk resolve:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to bulk resolve conflicts');
|
(window as any).showToast.error("Failed to bulk resolve conflicts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bulkDismiss(conflictIds: string[]): Promise<void> {
|
async function bulkDismiss(conflictIds: string[]): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/conflicts/bulk-dismiss', {
|
const response = await fetch("/api/conflicts/bulk-dismiss", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ conflict_ids: conflictIds })
|
body: JSON.stringify({ conflict_ids: conflictIds }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Conflicts dismissed');
|
(window as any).showToast.success("Conflicts dismissed");
|
||||||
}
|
}
|
||||||
refreshConflicts();
|
refreshConflicts();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to dismiss conflicts:', error);
|
console.error("Failed to dismiss conflicts:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to dismiss conflicts');
|
(window as any).showToast.error("Failed to dismiss conflicts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dismissAllResolved(): Promise<void> {
|
async function dismissAllResolved(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/conflicts/dismiss-resolved', {
|
const response = await fetch("/api/conflicts/dismiss-resolved", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Resolved conflicts dismissed');
|
(window as any).showToast.success("Resolved conflicts dismissed");
|
||||||
}
|
}
|
||||||
refreshConflicts();
|
refreshConflicts();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to dismiss resolved:', error);
|
console.error("Failed to dismiss resolved:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to dismiss resolved conflicts');
|
(window as any).showToast.error("Failed to dismiss resolved conflicts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
|
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
|
||||||
const container = document.getElementById('conflicts-list');
|
const container = document.getElementById("conflicts-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (conflicts.length === 0) {
|
if (conflicts.length === 0) {
|
||||||
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
|
container.innerHTML =
|
||||||
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = conflicts.map(conflict => `
|
container.innerHTML = conflicts
|
||||||
|
.map(
|
||||||
|
(conflict) => `
|
||||||
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
|
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
|
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
|
||||||
</div>
|
</div>
|
||||||
${conflict.resolution_status === 'unresolved' ? `
|
${
|
||||||
|
conflict.resolution_status === "unresolved"
|
||||||
|
? `
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
|
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateConflictStats(data: ConflictListResponse): void {
|
function updateConflictStats(data: ConflictListResponse): void {
|
||||||
const totalEl = document.getElementById('conflicts-total');
|
const totalEl = document.getElementById("conflicts-total");
|
||||||
const unresolvedEl = document.getElementById('conflicts-unresolved');
|
const unresolvedEl = document.getElementById("conflicts-unresolved");
|
||||||
|
|
||||||
if (totalEl) totalEl.textContent = String(data.total);
|
if (totalEl) totalEl.textContent = String(data.total);
|
||||||
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
|
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showResolveModal(conflictId: string): void {
|
function showResolveModal(conflictId: string): void {
|
||||||
const modal = document.getElementById('resolve-modal');
|
const modal = document.getElementById("resolve-modal");
|
||||||
const conflictIdInput = document.getElementById('resolve-conflict-id') as HTMLInputElement;
|
const conflictIdInput = document.getElementById(
|
||||||
|
"resolve-conflict-id",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
if (modal && conflictIdInput) {
|
if (modal && conflictIdInput) {
|
||||||
conflictIdInput.value = conflictId;
|
conflictIdInput.value = conflictId;
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideResolveModal(): void {
|
function hideResolveModal(): void {
|
||||||
const modal = document.getElementById('resolve-modal');
|
const modal = document.getElementById("resolve-modal");
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.add('hidden');
|
modal.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,12 +206,16 @@ function handleResolveSubmit(event: Event): void {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const form = event.target as HTMLFormElement;
|
const form = event.target as HTMLFormElement;
|
||||||
const conflictId = (form.querySelector('#resolve-conflict-id') as HTMLInputElement)?.value;
|
const conflictId = (
|
||||||
const winner = (form.querySelector('input[name="winner"]:checked') as HTMLInputElement)?.value;
|
form.querySelector("#resolve-conflict-id") as HTMLInputElement
|
||||||
|
)?.value;
|
||||||
|
const winner = (
|
||||||
|
form.querySelector('input[name="winner"]:checked') as HTMLInputElement
|
||||||
|
)?.value;
|
||||||
|
|
||||||
if (!conflictId || !winner) {
|
if (!conflictId || !winner) {
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Please select a winner');
|
(window as any).showToast.error("Please select a winner");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+285
-201
@@ -2,7 +2,7 @@ interface FilterField {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
operators: Operator[];
|
operators: Operator[];
|
||||||
valueType: 'text' | 'number' | 'date' | 'select' | 'multiselect';
|
valueType: "text" | "number" | "date" | "select" | "multiselect";
|
||||||
options?: string[];
|
options?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,153 +22,173 @@ interface FilterRule {
|
|||||||
|
|
||||||
const FILTER_FIELDS: FilterField[] = [
|
const FILTER_FIELDS: FilterField[] = [
|
||||||
{
|
{
|
||||||
id: 'title',
|
id: "title",
|
||||||
label: 'Title',
|
label: "Title",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
{ id: "contains", label: "Contains", requiresValue: true },
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'starts_with', label: 'Starts With', requiresValue: true },
|
{ id: "starts_with", label: "Starts With", requiresValue: true },
|
||||||
{ id: 'ends_with', label: 'Ends With', requiresValue: true },
|
{ id: "ends_with", label: "Ends With", requiresValue: true },
|
||||||
{ id: 'regex', label: 'Matches Regex', requiresValue: true },
|
{ id: "regex", label: "Matches Regex", requiresValue: true },
|
||||||
],
|
],
|
||||||
valueType: 'text',
|
valueType: "text",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'author',
|
id: "author",
|
||||||
label: 'Author',
|
label: "Author",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
{ id: "contains", label: "Contains", requiresValue: true },
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
],
|
],
|
||||||
valueType: 'text',
|
valueType: "text",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'genre',
|
id: "genre",
|
||||||
label: 'Genre',
|
label: "Genre",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||||||
{ id: 'in', label: 'In', requiresValue: true },
|
{ id: "in", label: "In", requiresValue: true },
|
||||||
{ id: 'not_in', label: 'Not In', requiresValue: true },
|
{ id: "not_in", label: "Not In", requiresValue: true },
|
||||||
|
],
|
||||||
|
valueType: "select",
|
||||||
|
options: [
|
||||||
|
"Fiction",
|
||||||
|
"Non-Fiction",
|
||||||
|
"Sci-Fi",
|
||||||
|
"Fantasy",
|
||||||
|
"Mystery",
|
||||||
|
"Romance",
|
||||||
|
"Thriller",
|
||||||
|
"Biography",
|
||||||
|
"History",
|
||||||
|
"Self-Help",
|
||||||
],
|
],
|
||||||
valueType: 'select',
|
|
||||||
options: ['Fiction', 'Non-Fiction', 'Sci-Fi', 'Fantasy', 'Mystery', 'Romance', 'Thriller', 'Biography', 'History', 'Self-Help'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'series',
|
id: "series",
|
||||||
label: 'Series',
|
label: "Series",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||||||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
{ id: "contains", label: "Contains", requiresValue: true },
|
||||||
],
|
],
|
||||||
valueType: 'text',
|
valueType: "text",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'progress',
|
id: "progress",
|
||||||
label: 'Reading Progress',
|
label: "Reading Progress",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||||||
{ id: 'greater_than', label: 'Greater Than', requiresValue: true },
|
{ id: "greater_than", label: "Greater Than", requiresValue: true },
|
||||||
{ id: 'less_than', label: 'Less Than', requiresValue: true },
|
{ id: "less_than", label: "Less Than", requiresValue: true },
|
||||||
{ id: 'between', label: 'Between', requiresValue: true },
|
{ id: "between", label: "Between", requiresValue: true },
|
||||||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||||||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||||||
],
|
],
|
||||||
valueType: 'number',
|
valueType: "number",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rating',
|
id: "rating",
|
||||||
label: 'Rating',
|
label: "Rating",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||||||
{ id: 'greater_than', label: 'Greater Than', requiresValue: true },
|
{ id: "greater_than", label: "Greater Than", requiresValue: true },
|
||||||
{ id: 'less_than', label: 'Less Than', requiresValue: true },
|
{ id: "less_than", label: "Less Than", requiresValue: true },
|
||||||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||||||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||||||
],
|
],
|
||||||
valueType: 'number',
|
valueType: "number",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'date_added',
|
id: "date_added",
|
||||||
label: 'Date Added',
|
label: "Date Added",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||||||
{ id: 'before', label: 'Before', requiresValue: true },
|
{ id: "before", label: "Before", requiresValue: true },
|
||||||
{ id: 'after', label: 'After', requiresValue: true },
|
{ id: "after", label: "After", requiresValue: true },
|
||||||
{ id: 'between', label: 'Between', requiresValue: true },
|
{ id: "between", label: "Between", requiresValue: true },
|
||||||
{ id: 'last_x_days', label: 'Last X Days', requiresValue: true },
|
{ id: "last_x_days", label: "Last X Days", requiresValue: true },
|
||||||
],
|
],
|
||||||
valueType: 'date',
|
valueType: "date",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'last_read',
|
id: "last_read",
|
||||||
label: 'Last Read Date',
|
label: "Last Read Date",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'before', label: 'Before', requiresValue: true },
|
{ id: "before", label: "Before", requiresValue: true },
|
||||||
{ id: 'after', label: 'After', requiresValue: true },
|
{ id: "after", label: "After", requiresValue: true },
|
||||||
{ id: 'between', label: 'Between', requiresValue: true },
|
{ id: "between", label: "Between", requiresValue: true },
|
||||||
{ id: 'last_x_days', label: 'Last X Days', requiresValue: true },
|
{ id: "last_x_days", label: "Last X Days", requiresValue: true },
|
||||||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||||||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||||||
],
|
],
|
||||||
valueType: 'date',
|
valueType: "date",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'publisher',
|
id: "publisher",
|
||||||
label: 'Publisher',
|
label: "Publisher",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
{ id: "contains", label: "Contains", requiresValue: true },
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
],
|
],
|
||||||
valueType: 'text',
|
valueType: "text",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'language',
|
id: "language",
|
||||||
label: 'Language',
|
label: "Language",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
|
{ id: "not_equals", label: "Not Equals", requiresValue: true },
|
||||||
{ id: 'in', label: 'In', requiresValue: true },
|
{ id: "in", label: "In", requiresValue: true },
|
||||||
|
],
|
||||||
|
valueType: "select",
|
||||||
|
options: [
|
||||||
|
"English",
|
||||||
|
"Spanish",
|
||||||
|
"French",
|
||||||
|
"German",
|
||||||
|
"Japanese",
|
||||||
|
"Chinese",
|
||||||
|
"Russian",
|
||||||
|
"Other",
|
||||||
],
|
],
|
||||||
valueType: 'select',
|
|
||||||
options: ['English', 'Spanish', 'French', 'German', 'Japanese', 'Chinese', 'Russian', 'Other'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'format',
|
id: "format",
|
||||||
label: 'Format',
|
label: "Format",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'in', label: 'In', requiresValue: true },
|
{ id: "in", label: "In", requiresValue: true },
|
||||||
],
|
],
|
||||||
valueType: 'select',
|
valueType: "select",
|
||||||
options: ['Ebook', 'Audiobook', 'Comic', 'Manga', 'Magazine'],
|
options: ["Ebook", "Audiobook", "Comic", "Manga", "Magazine"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tags',
|
id: "tags",
|
||||||
label: 'Tags',
|
label: "Tags",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
{ id: "contains", label: "Contains", requiresValue: true },
|
||||||
{ id: 'not_contains', label: 'Does Not Contain', requiresValue: true },
|
{ id: "not_contains", label: "Does Not Contain", requiresValue: true },
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
],
|
],
|
||||||
valueType: 'text',
|
valueType: "text",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'narrators',
|
id: "narrators",
|
||||||
label: 'Narrators (Audiobooks)',
|
label: "Narrators (Audiobooks)",
|
||||||
operators: [
|
operators: [
|
||||||
{ id: 'contains', label: 'Contains', requiresValue: true },
|
{ id: "contains", label: "Contains", requiresValue: true },
|
||||||
{ id: 'equals', label: 'Equals', requiresValue: true },
|
{ id: "equals", label: "Equals", requiresValue: true },
|
||||||
{ id: 'is_set', label: 'Is Set', requiresValue: false },
|
{ id: "is_set", label: "Is Set", requiresValue: false },
|
||||||
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
|
{ id: "is_not_set", label: "Is Not Set", requiresValue: false },
|
||||||
],
|
],
|
||||||
valueType: 'text',
|
valueType: "text",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -177,29 +197,29 @@ let selectedBooks: Map<string, BookInfo> = new Map();
|
|||||||
let customSectionTimeout: number | null = null;
|
let customSectionTimeout: number | null = null;
|
||||||
|
|
||||||
function initCustomSectionBuilder(): void {
|
function initCustomSectionBuilder(): void {
|
||||||
const addRuleBtn = document.getElementById('add-rule-btn');
|
const addRuleBtn = document.getElementById("add-rule-btn");
|
||||||
const previewBtn = document.getElementById('preview-btn');
|
const previewBtn = document.getElementById("preview-btn");
|
||||||
const searchBtn = document.getElementById('search-books-btn');
|
const searchBtn = document.getElementById("search-books-btn");
|
||||||
const bookSearchInput = document.getElementById('book-search');
|
const bookSearchInput = document.getElementById("book-search");
|
||||||
const cancelBtn = document.getElementById('cancel-btn');
|
const cancelBtn = document.getElementById("cancel-btn");
|
||||||
const form = document.getElementById('custom-section-form');
|
const form = document.getElementById("custom-section-form");
|
||||||
|
|
||||||
if (addRuleBtn) {
|
if (addRuleBtn) {
|
||||||
addRuleBtn.addEventListener('click', addFilterRule);
|
addRuleBtn.addEventListener("click", addFilterRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previewBtn) {
|
if (previewBtn) {
|
||||||
previewBtn.addEventListener('click', loadPreview);
|
previewBtn.addEventListener("click", loadPreview);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchBtn) {
|
if (searchBtn) {
|
||||||
searchBtn.addEventListener('click', searchBooks);
|
searchBtn.addEventListener("click", searchBooks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bookSearchInput) {
|
if (bookSearchInput) {
|
||||||
bookSearchInput.addEventListener('input', onBookSearchInput);
|
bookSearchInput.addEventListener("input", onBookSearchInput);
|
||||||
bookSearchInput.addEventListener('keypress', (e) => {
|
bookSearchInput.addEventListener("keypress", (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
searchBooks();
|
searchBooks();
|
||||||
}
|
}
|
||||||
@@ -207,25 +227,25 @@ function initCustomSectionBuilder(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cancelBtn) {
|
if (cancelBtn) {
|
||||||
cancelBtn.addEventListener('click', () => {
|
cancelBtn.addEventListener("click", () => {
|
||||||
window.location.href = '/dashboard';
|
window.location.href = "/dashboard";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form) {
|
if (form) {
|
||||||
form.addEventListener('submit', saveCustomSection);
|
form.addEventListener("submit", saveCustomSection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFilterRule(): void {
|
function addFilterRule(): void {
|
||||||
const container = document.getElementById('rules-container');
|
const container = document.getElementById("rules-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
ruleCounter++;
|
ruleCounter++;
|
||||||
const ruleId = `rule-${ruleCounter}`;
|
const ruleId = `rule-${ruleCounter}`;
|
||||||
|
|
||||||
const ruleElement = document.createElement('div');
|
const ruleElement = document.createElement("div");
|
||||||
ruleElement.className = 'rule-item p-3 rounded border';
|
ruleElement.className = "rule-item p-3 rounded border";
|
||||||
ruleElement.dataset.ruleId = ruleId;
|
ruleElement.dataset.ruleId = ruleId;
|
||||||
ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
|
ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
|
||||||
|
|
||||||
@@ -234,7 +254,7 @@ function addFilterRule(): void {
|
|||||||
<select class="field-select flex-1 px-3 py-1 rounded border"
|
<select class="field-select flex-1 px-3 py-1 rounded border"
|
||||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
|
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
|
||||||
<option value="">Select field...</option>
|
<option value="">Select field...</option>
|
||||||
${FILTER_FIELDS.map(field => `<option value="${field.id}">${field.label}</option>`).join('')}
|
${FILTER_FIELDS.map((field) => `<option value="${field.id}">${field.label}</option>`).join("")}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="remove-rule-btn text-red-500 hover:text-red-700 px-2" data-rule-id="${ruleId}">
|
<button type="button" class="remove-rule-btn text-red-500 hover:text-red-700 px-2" data-rule-id="${ruleId}">
|
||||||
Remove
|
Remove
|
||||||
@@ -254,50 +274,69 @@ function addFilterRule(): void {
|
|||||||
|
|
||||||
container.appendChild(ruleElement);
|
container.appendChild(ruleElement);
|
||||||
|
|
||||||
const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement;
|
const fieldSelect = ruleElement.querySelector(
|
||||||
const removeBtn = ruleElement.querySelector('.remove-rule-btn') as HTMLButtonElement;
|
".field-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const removeBtn = ruleElement.querySelector(
|
||||||
|
".remove-rule-btn",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
fieldSelect.addEventListener('change', () => onFieldChange(ruleElement));
|
fieldSelect.addEventListener("change", () => onFieldChange(ruleElement));
|
||||||
removeBtn.addEventListener('click', () => removeFilterRule(ruleId));
|
removeBtn.addEventListener("click", () => removeFilterRule(ruleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFieldChange(ruleElement: HTMLElement): void {
|
function onFieldChange(ruleElement: HTMLElement): void {
|
||||||
const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement;
|
const fieldSelect = ruleElement.querySelector(
|
||||||
const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement;
|
".field-select",
|
||||||
const valueInput = ruleElement.querySelector('.value-input') as HTMLInputElement;
|
) as HTMLSelectElement;
|
||||||
|
const operatorSelect = ruleElement.querySelector(
|
||||||
|
".operator-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const valueInput = ruleElement.querySelector(
|
||||||
|
".value-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
const fieldId = fieldSelect.value;
|
const fieldId = fieldSelect.value;
|
||||||
const field = FILTER_FIELDS.find(f => f.id === fieldId);
|
const field = FILTER_FIELDS.find((f) => f.id === fieldId);
|
||||||
|
|
||||||
operatorSelect.innerHTML = field
|
operatorSelect.innerHTML = field
|
||||||
? field.operators.map(op => `<option value="${op.id}">${op.label}</option>`).join('')
|
? field.operators
|
||||||
|
.map((op) => `<option value="${op.id}">${op.label}</option>`)
|
||||||
|
.join("")
|
||||||
: '<option value="">Select field first...</option>';
|
: '<option value="">Select field first...</option>';
|
||||||
|
|
||||||
operatorSelect.disabled = !field;
|
operatorSelect.disabled = !field;
|
||||||
|
|
||||||
if (field && field.operators.some(op => op.id === operatorSelect.value && op.requiresValue)) {
|
if (
|
||||||
valueInput.classList.remove('hidden');
|
field &&
|
||||||
|
field.operators.some(
|
||||||
|
(op) => op.id === operatorSelect.value && op.requiresValue,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
valueInput.classList.remove("hidden");
|
||||||
|
|
||||||
if (field.valueType === 'select' && field.options) {
|
if (field.valueType === "select" && field.options) {
|
||||||
valueInput.type = 'select';
|
valueInput.type = "select";
|
||||||
} else if (field.valueType === 'number') {
|
} else if (field.valueType === "number") {
|
||||||
valueInput.type = 'number';
|
valueInput.type = "number";
|
||||||
valueInput.step = '0.01';
|
valueInput.step = "0.01";
|
||||||
} else if (field.valueType === 'date') {
|
} else if (field.valueType === "date") {
|
||||||
valueInput.type = 'date';
|
valueInput.type = "date";
|
||||||
} else {
|
} else {
|
||||||
valueInput.type = 'text';
|
valueInput.type = "text";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
valueInput.classList.add('hidden');
|
valueInput.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
operatorSelect.addEventListener('change', () => {
|
operatorSelect.addEventListener("change", () => {
|
||||||
const selectedOp = field?.operators.find(op => op.id === operatorSelect.value);
|
const selectedOp = field?.operators.find(
|
||||||
|
(op) => op.id === operatorSelect.value,
|
||||||
|
);
|
||||||
if (selectedOp?.requiresValue) {
|
if (selectedOp?.requiresValue) {
|
||||||
valueInput.classList.remove('hidden');
|
valueInput.classList.remove("hidden");
|
||||||
} else {
|
} else {
|
||||||
valueInput.classList.add('hidden');
|
valueInput.classList.add("hidden");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -319,50 +358,64 @@ function onBookSearchInput(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function searchBooks(): Promise<void> {
|
async function searchBooks(): Promise<void> {
|
||||||
const searchInput = document.getElementById('book-search') as HTMLInputElement;
|
const searchInput = document.getElementById(
|
||||||
const librarySelect = document.getElementById('section-library') as HTMLSelectElement;
|
"book-search",
|
||||||
const resultsContainer = document.getElementById('search-results') as HTMLElement;
|
) as HTMLInputElement;
|
||||||
|
const librarySelect = document.getElementById(
|
||||||
|
"section-library",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const resultsContainer = document.getElementById(
|
||||||
|
"search-results",
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
const query = searchInput?.value.trim();
|
const query = searchInput?.value.trim();
|
||||||
const libraryId = librarySelect?.value;
|
const libraryId = librarySelect?.value;
|
||||||
|
|
||||||
if (!query || !libraryId) {
|
if (!query || !libraryId) {
|
||||||
if (resultsContainer) resultsContainer.classList.add('hidden');
|
if (resultsContainer) resultsContainer.classList.add("hidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, {
|
const response = await fetch(
|
||||||
|
`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to search books');
|
throw new Error("Failed to search books");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
displaySearchResults(data.books || []);
|
displaySearchResults(data.books || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search books error:', error);
|
console.error("Search books error:", error);
|
||||||
(window as any).showToast?.error('Failed to search books');
|
(window as any).showToast?.error("Failed to search books");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function displaySearchResults(books: BookInfo[]): void {
|
function displaySearchResults(books: BookInfo[]): void {
|
||||||
const resultsContainer = document.getElementById('search-results') as HTMLElement;
|
const resultsContainer = document.getElementById(
|
||||||
|
"search-results",
|
||||||
|
) as HTMLElement;
|
||||||
if (!resultsContainer) return;
|
if (!resultsContainer) return;
|
||||||
|
|
||||||
if (books.length === 0) {
|
if (books.length === 0) {
|
||||||
resultsContainer.innerHTML = '<p class="text-center" style="color: var(--text-secondary);">No books found</p>';
|
resultsContainer.innerHTML =
|
||||||
|
'<p class="text-center" style="color: var(--text-secondary);">No books found</p>';
|
||||||
} else {
|
} else {
|
||||||
resultsContainer.innerHTML = books.map(book => `
|
resultsContainer.innerHTML = books
|
||||||
|
.map(
|
||||||
|
(book) => `
|
||||||
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||||||
data-book-id="${book.media_item_id}"
|
data-book-id="${book.media_item_id}"
|
||||||
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')">
|
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')">
|
||||||
<img src="${book.cover_image_path || '/static/placeholder-book.svg'}"
|
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
|
||||||
alt="${builderEscapeHtml(book.title)}"
|
alt="${builderEscapeHtml(book.title)}"
|
||||||
class="w-10 h-15 object-cover rounded">
|
class="w-10 h-15 object-cover rounded">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -371,15 +424,21 @@ function displaySearchResults(books: BookInfo[]): void {
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
|
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsContainer.classList.remove('hidden');
|
resultsContainer.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as any).addBookToSelection = function(bookId: string, title: string, author: string): void {
|
(window as any).addBookToSelection = function (
|
||||||
|
bookId: string,
|
||||||
|
title: string,
|
||||||
|
author: string,
|
||||||
|
): void {
|
||||||
if (selectedBooks.has(bookId)) {
|
if (selectedBooks.has(bookId)) {
|
||||||
(window as any).showToast?.warning('Book already selected');
|
(window as any).showToast?.warning("Book already selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,53 +446,63 @@ function displaySearchResults(books: BookInfo[]): void {
|
|||||||
media_item_id: bookId,
|
media_item_id: bookId,
|
||||||
title: title,
|
title: title,
|
||||||
author: author,
|
author: author,
|
||||||
cover_image_path: '',
|
cover_image_path: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSelectedBooksDisplay();
|
updateSelectedBooksDisplay();
|
||||||
};
|
};
|
||||||
|
|
||||||
(window as any).removeBookFromSelection = function(bookId: string): void {
|
(window as any).removeBookFromSelection = function (bookId: string): void {
|
||||||
selectedBooks.delete(bookId);
|
selectedBooks.delete(bookId);
|
||||||
updateSelectedBooksDisplay();
|
updateSelectedBooksDisplay();
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateSelectedBooksDisplay(): void {
|
function updateSelectedBooksDisplay(): void {
|
||||||
const container = document.getElementById('selected-books') as HTMLElement;
|
const container = document.getElementById("selected-books") as HTMLElement;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (selectedBooks.size === 0) {
|
if (selectedBooks.size === 0) {
|
||||||
container.innerHTML = '<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>';
|
container.innerHTML =
|
||||||
|
'<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = Array.from(selectedBooks.values()).map(book => `
|
container.innerHTML = Array.from(selectedBooks.values())
|
||||||
|
.map(
|
||||||
|
(book) => `
|
||||||
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
||||||
style="background-color: var(--accent);">
|
style="background-color: var(--accent);">
|
||||||
<span>${builderEscapeHtml(book.title)}</span>
|
<span>${builderEscapeHtml(book.title)}</span>
|
||||||
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
||||||
class="hover:opacity-70">×</button>
|
class="hover:opacity-70">×</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPreview(): Promise<void> {
|
async function loadPreview(): Promise<void> {
|
||||||
const previewContainer = document.getElementById('preview-container') as HTMLElement;
|
const previewContainer = document.getElementById(
|
||||||
const librarySelect = document.getElementById('section-library') as HTMLSelectElement;
|
"preview-container",
|
||||||
|
) as HTMLElement;
|
||||||
|
const librarySelect = document.getElementById(
|
||||||
|
"section-library",
|
||||||
|
) as HTMLSelectElement;
|
||||||
const libraryId = librarySelect?.value;
|
const libraryId = librarySelect?.value;
|
||||||
|
|
||||||
if (!libraryId) {
|
if (!libraryId) {
|
||||||
(window as any).showToast?.error('Please select a library first');
|
(window as any).showToast?.error("Please select a library first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = gatherFilterRules();
|
const rules = gatherFilterRules();
|
||||||
const manualBookIds = Array.from(selectedBooks.keys());
|
const manualBookIds = Array.from(selectedBooks.keys());
|
||||||
|
|
||||||
previewContainer.innerHTML = '<div class="text-center"><div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent rounded-full"></div></div>';
|
previewContainer.innerHTML =
|
||||||
|
'<div class="text-center"><div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent rounded-full"></div></div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.post('/collections/preview', {
|
const response = await (window as any).api.post("/collections/preview", {
|
||||||
library_id: libraryId,
|
library_id: libraryId,
|
||||||
rules: rules,
|
rules: rules,
|
||||||
manual_book_ids: manualBookIds,
|
manual_book_ids: manualBookIds,
|
||||||
@@ -444,25 +513,32 @@ async function loadPreview(): Promise<void> {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
displayPreview(data.items || []);
|
displayPreview(data.items || []);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Failed to load preview');
|
throw new Error("Failed to load preview");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Preview error:', error);
|
console.error("Preview error:", error);
|
||||||
previewContainer.innerHTML = '<p class="text-center text-red-500">Failed to load preview</p>';
|
previewContainer.innerHTML =
|
||||||
|
'<p class="text-center text-red-500">Failed to load preview</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatherFilterRules(): FilterRule[] {
|
function gatherFilterRules(): FilterRule[] {
|
||||||
const container = document.getElementById('rules-container') as HTMLElement;
|
const container = document.getElementById("rules-container") as HTMLElement;
|
||||||
if (!container) return [];
|
if (!container) return [];
|
||||||
|
|
||||||
const ruleElements = container.querySelectorAll('.rule-item');
|
const ruleElements = container.querySelectorAll(".rule-item");
|
||||||
const rules: FilterRule[] = [];
|
const rules: FilterRule[] = [];
|
||||||
|
|
||||||
ruleElements.forEach((element, index) => {
|
ruleElements.forEach((element, index) => {
|
||||||
const fieldSelect = element.querySelector('.field-select') as HTMLSelectElement;
|
const fieldSelect = element.querySelector(
|
||||||
const operatorSelect = element.querySelector('.operator-select') as HTMLSelectElement;
|
".field-select",
|
||||||
const valueInput = element.querySelector('.value-input') as HTMLInputElement;
|
) as HTMLSelectElement;
|
||||||
|
const operatorSelect = element.querySelector(
|
||||||
|
".operator-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const valueInput = element.querySelector(
|
||||||
|
".value-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
if (fieldSelect.value && operatorSelect.value) {
|
if (fieldSelect.value && operatorSelect.value) {
|
||||||
rules.push({
|
rules.push({
|
||||||
@@ -479,32 +555,39 @@ function gatherFilterRules(): FilterRule[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayPreview(items: BookInfo[]): void {
|
function displayPreview(items: BookInfo[]): void {
|
||||||
const previewContainer = document.getElementById('preview-container') as HTMLElement;
|
const previewContainer = document.getElementById(
|
||||||
|
"preview-container",
|
||||||
|
) as HTMLElement;
|
||||||
if (!previewContainer) return;
|
if (!previewContainer) return;
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
previewContainer.innerHTML = '<p class="text-center" style="color: var(--text-secondary);">No items match your criteria</p>';
|
previewContainer.innerHTML =
|
||||||
|
'<p class="text-center" style="color: var(--text-secondary);">No items match your criteria</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
previewContainer.innerHTML = `
|
previewContainer.innerHTML = `
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4">
|
<div class="flex gap-4 overflow-x-auto pb-4">
|
||||||
${items.map(item => `
|
${items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
<div class="flex-shrink-0 w-32">
|
<div class="flex-shrink-0 w-32">
|
||||||
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
|
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
|
||||||
<img src="${item.cover_image_path || '/static/placeholder-book.svg'}"
|
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}"
|
||||||
alt="${builderEscapeHtml(item.title)}"
|
alt="${builderEscapeHtml(item.title)}"
|
||||||
class="w-full h-full object-cover">
|
class="w-full h-full object-cover">
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
|
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
|
||||||
${builderEscapeHtml(item.title)}
|
${builderEscapeHtml(item.title)}
|
||||||
</h3>
|
</h3>
|
||||||
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${builderEscapeHtml(item.author)}</p>` : ''}
|
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${builderEscapeHtml(item.author)}</p>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-center mt-2" style="color: var(--text-secondary);">
|
<p class="text-sm text-center mt-2" style="color: var(--text-secondary);">
|
||||||
${items.length} item${items.length !== 1 ? 's' : ''} will be shown
|
${items.length} item${items.length !== 1 ? "s" : ""} will be shown
|
||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -513,14 +596,15 @@ async function saveCustomSection(event: Event): Promise<void> {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(event.target as HTMLFormElement);
|
const formData = new FormData(event.target as HTMLFormElement);
|
||||||
const libraryId = formData.get('library_id') as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
const name = formData.get('name') as string;
|
const name = formData.get("name") as string;
|
||||||
const icon = formData.get('icon') as string;
|
const icon = formData.get("icon") as string;
|
||||||
const description = formData.get('description') as string;
|
const description = formData.get("description") as string;
|
||||||
const matchType = (document.getElementById('match-type') as HTMLSelectElement).value;
|
const matchType = (document.getElementById("match-type") as HTMLSelectElement)
|
||||||
|
.value;
|
||||||
|
|
||||||
if (!libraryId || !name) {
|
if (!libraryId || !name) {
|
||||||
(window as any).showToast?.error('Please fill in required fields');
|
(window as any).showToast?.error("Please fill in required fields");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,12 +612,12 @@ async function saveCustomSection(event: Event): Promise<void> {
|
|||||||
const manualBookIds = Array.from(selectedBooks.keys());
|
const manualBookIds = Array.from(selectedBooks.keys());
|
||||||
|
|
||||||
if (rules.length === 0 && manualBookIds.length === 0) {
|
if (rules.length === 0 && manualBookIds.length === 0) {
|
||||||
(window as any).showToast?.error('Please add filter rules or select books');
|
(window as any).showToast?.error("Please add filter rules or select books");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.post('/collections', {
|
const response = await (window as any).api.post("/collections", {
|
||||||
library_id: libraryId,
|
library_id: libraryId,
|
||||||
name: name,
|
name: name,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
@@ -545,23 +629,23 @@ async function saveCustomSection(event: Event): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
(window as any).showToast?.success('Custom section created successfully');
|
(window as any).showToast?.success("Custom section created successfully");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/dashboard';
|
window.location.href = "/dashboard";
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Failed to save custom section');
|
throw new Error("Failed to save custom section");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save custom section error:', error);
|
console.error("Save custom section error:", error);
|
||||||
(window as any).showToast?.error('Failed to save custom section');
|
(window as any).showToast?.error("Failed to save custom section");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function builderEscapeHtml(text: string): string {
|
function builderEscapeHtml(text: string): string {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement("div");
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initCustomSectionBuilder);
|
document.addEventListener("DOMContentLoaded", initCustomSectionBuilder);
|
||||||
|
|||||||
+32
-24
@@ -1,23 +1,25 @@
|
|||||||
function toggleSidebar(): void {
|
function toggleSidebar(): void {
|
||||||
const sidebar = document.getElementById('docs-sidebar');
|
const sidebar = document.getElementById("docs-sidebar");
|
||||||
const overlay = document.getElementById('docs-overlay');
|
const overlay = document.getElementById("docs-overlay");
|
||||||
|
|
||||||
if (sidebar && overlay) {
|
if (sidebar && overlay) {
|
||||||
sidebar.classList.toggle('translate-x-0');
|
sidebar.classList.toggle("translate-x-0");
|
||||||
sidebar.classList.toggle('-translate-x-full');
|
sidebar.classList.toggle("-translate-x-full");
|
||||||
overlay.classList.toggle('hidden');
|
overlay.classList.toggle("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeDocsSearch(): void {
|
function initializeDocsSearch(): void {
|
||||||
const searchInput = document.getElementById('docs-search') as HTMLInputElement;
|
const searchInput = document.getElementById(
|
||||||
const searchResults = document.getElementById('docs-search-results');
|
"docs-search",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const searchResults = document.getElementById("docs-search-results");
|
||||||
|
|
||||||
if (!searchInput || !searchResults) return;
|
if (!searchInput || !searchResults) return;
|
||||||
|
|
||||||
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener("input", () => {
|
||||||
const query = searchInput.value.trim();
|
const query = searchInput.value.trim();
|
||||||
|
|
||||||
if (docsSearchTimeout) {
|
if (docsSearchTimeout) {
|
||||||
@@ -25,8 +27,8 @@ function initializeDocsSearch(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
searchResults.innerHTML = '';
|
searchResults.innerHTML = "";
|
||||||
searchResults.classList.add('hidden');
|
searchResults.classList.add("hidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,49 +39,55 @@ function initializeDocsSearch(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function performDocsSearch(query: string): void {
|
function performDocsSearch(query: string): void {
|
||||||
const searchResults = document.getElementById('docs-search-results');
|
const searchResults = document.getElementById("docs-search-results");
|
||||||
if (!searchResults) return;
|
if (!searchResults) return;
|
||||||
|
|
||||||
if (!(window as any).lunr) {
|
if (!(window as any).lunr) {
|
||||||
console.warn('Lunr.js not loaded');
|
console.warn("Lunr.js not loaded");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const idx = (window as any).lunrIndex;
|
const idx = (window as any).lunrIndex;
|
||||||
if (!idx) {
|
if (!idx) {
|
||||||
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
|
searchResults.innerHTML =
|
||||||
searchResults.classList.remove('hidden');
|
'<p class="p-2 text-sm" style="color: var(--text-secondary)">Search index not loaded</p>';
|
||||||
|
searchResults.classList.remove("hidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = idx.search(query);
|
const results = idx.search(query);
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
|
searchResults.innerHTML =
|
||||||
|
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
|
||||||
} else {
|
} else {
|
||||||
searchResults.innerHTML = results.slice(0, 10).map((result: { ref: string }) => {
|
searchResults.innerHTML = results
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((result: { ref: string }) => {
|
||||||
const doc = (window as any).docsData?.[result.ref];
|
const doc = (window as any).docsData?.[result.ref];
|
||||||
if (!doc) return '';
|
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)">
|
<a href="${result.ref}" class="block p-2 hover:bg-opacity-50 transition-colors" style="background-color: var(--bg-secondary)">
|
||||||
<p class="font-medium text-sm" style="color: var(--text-primary)">${doc.title || result.ref}</p>
|
<p class="font-medium text-sm" style="color: var(--text-primary)">${doc.title || result.ref}</p>
|
||||||
${doc.section ? `<p class="text-xs" style="color: var(--text-secondary)">${doc.section}</p>` : ''}
|
${doc.section ? `<p class="text-xs" style="color: var(--text-secondary)">${doc.section}</p>` : ""}
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
})
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove("hidden");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
console.error("Search error:", error);
|
||||||
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
|
searchResults.innerHTML =
|
||||||
searchResults.classList.remove('hidden');
|
'<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
|
||||||
|
searchResults.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initializeDocsSearch();
|
initializeDocsSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -1,5 +1,5 @@
|
|||||||
function escapeHtml(text: string): string {
|
function escapeHtml(text: string): string {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement("div");
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
@@ -19,15 +19,15 @@ function getElementById<T extends HTMLElement>(id: string): T | null {
|
|||||||
function createElement<K extends keyof HTMLElementTagNameMap>(
|
function createElement<K extends keyof HTMLElementTagNameMap>(
|
||||||
tagName: K,
|
tagName: K,
|
||||||
attributes?: Record<string, string>,
|
attributes?: Record<string, string>,
|
||||||
children?: (string | Node)[]
|
children?: (string | Node)[],
|
||||||
): HTMLElementTagNameMap[K] {
|
): HTMLElementTagNameMap[K] {
|
||||||
const element = document.createElement(tagName);
|
const element = document.createElement(tagName);
|
||||||
|
|
||||||
if (attributes) {
|
if (attributes) {
|
||||||
Object.entries(attributes).forEach(([key, value]) => {
|
Object.entries(attributes).forEach(([key, value]) => {
|
||||||
if (key === 'className') {
|
if (key === "className") {
|
||||||
element.className = value;
|
element.className = value;
|
||||||
} else if (key === 'dataset') {
|
} else if (key === "dataset") {
|
||||||
Object.entries(JSON.parse(value)).forEach(([dataKey, dataValue]) => {
|
Object.entries(JSON.parse(value)).forEach(([dataKey, dataValue]) => {
|
||||||
element.dataset[dataKey] = String(dataValue);
|
element.dataset[dataKey] = String(dataValue);
|
||||||
});
|
});
|
||||||
@@ -38,8 +38,8 @@ function createElement<K extends keyof HTMLElementTagNameMap>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (children) {
|
if (children) {
|
||||||
children.forEach(child => {
|
children.forEach((child) => {
|
||||||
if (typeof child === 'string') {
|
if (typeof child === "string") {
|
||||||
element.appendChild(document.createTextNode(child));
|
element.appendChild(document.createTextNode(child));
|
||||||
} else {
|
} else {
|
||||||
element.appendChild(child);
|
element.appendChild(child);
|
||||||
@@ -52,19 +52,19 @@ function createElement<K extends keyof HTMLElementTagNameMap>(
|
|||||||
|
|
||||||
function showElement(element: HTMLElement | null): void {
|
function showElement(element: HTMLElement | null): void {
|
||||||
if (element) {
|
if (element) {
|
||||||
element.classList.remove('hidden');
|
element.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideElement(element: HTMLElement | null): void {
|
function hideElement(element: HTMLElement | null): void {
|
||||||
if (element) {
|
if (element) {
|
||||||
element.classList.add('hidden');
|
element.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleElement(element: HTMLElement | null): void {
|
function toggleElement(element: HTMLElement | null): void {
|
||||||
if (element) {
|
if (element) {
|
||||||
element.classList.toggle('hidden');
|
element.classList.toggle("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ function hasClass(element: HTMLElement | null, className: string): boolean {
|
|||||||
addClass,
|
addClass,
|
||||||
removeClass,
|
removeClass,
|
||||||
toggleClass,
|
toggleClass,
|
||||||
hasClass
|
hasClass,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -133,5 +133,5 @@ export {
|
|||||||
addClass,
|
addClass,
|
||||||
removeClass,
|
removeClass,
|
||||||
toggleClass,
|
toggleClass,
|
||||||
hasClass
|
hasClass,
|
||||||
};
|
};
|
||||||
|
|||||||
+36
-30
@@ -1,27 +1,27 @@
|
|||||||
// Header functionality
|
// Header functionality
|
||||||
|
|
||||||
const toggleThemeDropdown = (): void => {
|
const toggleThemeDropdown = (): void => {
|
||||||
const dropdown = document.getElementById('theme-dropdown');
|
const dropdown = document.getElementById("theme-dropdown");
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.classList.toggle('hidden');
|
dropdown.classList.toggle("hidden");
|
||||||
|
|
||||||
// Close user menu if open
|
// Close user menu if open
|
||||||
const userMenu = document.getElementById('user-menu');
|
const userMenu = document.getElementById("user-menu");
|
||||||
if (userMenu && !dropdown.classList.contains('hidden')) {
|
if (userMenu && !dropdown.classList.contains("hidden")) {
|
||||||
userMenu.classList.add('hidden');
|
userMenu.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleUserMenu = (): void => {
|
const toggleUserMenu = (): void => {
|
||||||
const menu = document.getElementById('user-menu');
|
const menu = document.getElementById("user-menu");
|
||||||
if (menu) {
|
if (menu) {
|
||||||
menu.classList.toggle('hidden');
|
menu.classList.toggle("hidden");
|
||||||
|
|
||||||
// Close theme dropdown if open
|
// Close theme dropdown if open
|
||||||
const themeDropdown = document.getElementById('theme-dropdown');
|
const themeDropdown = document.getElementById("theme-dropdown");
|
||||||
if (themeDropdown && !menu.classList.contains('hidden')) {
|
if (themeDropdown && !menu.classList.contains("hidden")) {
|
||||||
themeDropdown.classList.add('hidden');
|
themeDropdown.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -33,48 +33,54 @@ const changeThemeTo = (theme: string): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to server if logged in
|
// Save to server if logged in
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (token) {
|
if (token) {
|
||||||
fetch('/api/auth/theme', {
|
fetch("/api/auth/theme", {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'Authorization': `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ theme })
|
body: JSON.stringify({ theme }),
|
||||||
}).catch(err => console.log('Theme save failed', err));
|
}).catch((err) => console.log("Theme save failed", err));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close dropdown
|
// Close dropdown
|
||||||
const dropdown = document.getElementById('theme-dropdown');
|
const dropdown = document.getElementById("theme-dropdown");
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.classList.add('hidden');
|
dropdown.classList.add("hidden");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = (): void => {
|
const logout = (): void => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem("user");
|
||||||
window.location.href = '/';
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close dropdowns when clicking outside
|
// Close dropdowns when clicking outside
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
const themeDropdown = document.getElementById('theme-dropdown');
|
const themeDropdown = document.getElementById("theme-dropdown");
|
||||||
const userMenu = document.getElementById('user-menu');
|
const userMenu = document.getElementById("user-menu");
|
||||||
const themeButton = target?.closest('button[onclick="toggleThemeDropdown()"]');
|
const themeButton = target?.closest(
|
||||||
|
'button[onclick="toggleThemeDropdown()"]',
|
||||||
|
);
|
||||||
const userButton = target?.closest('button[onclick="toggleUserMenu()"]');
|
const userButton = target?.closest('button[onclick="toggleUserMenu()"]');
|
||||||
|
|
||||||
if (!themeButton && themeDropdown && !themeDropdown.classList.contains('hidden')) {
|
if (
|
||||||
|
!themeButton &&
|
||||||
|
themeDropdown &&
|
||||||
|
!themeDropdown.classList.contains("hidden")
|
||||||
|
) {
|
||||||
if (!themeDropdown.contains(target)) {
|
if (!themeDropdown.contains(target)) {
|
||||||
themeDropdown.classList.add('hidden');
|
themeDropdown.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userButton && userMenu && !userMenu.classList.contains('hidden')) {
|
if (!userButton && userMenu && !userMenu.classList.contains("hidden")) {
|
||||||
if (!userMenu.contains(target)) {
|
if (!userMenu.contains(target)) {
|
||||||
userMenu.classList.add('hidden');
|
userMenu.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+214
-135
@@ -48,99 +48,128 @@ let users: User[] = [];
|
|||||||
// Reload libraries from API (called after create/delete/update)
|
// Reload libraries from API (called after create/delete/update)
|
||||||
async function reloadLibraries(): Promise<void> {
|
async function reloadLibraries(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.get('/libraries');
|
const response = await (window as any).api.get("/libraries");
|
||||||
const result = await (window as any).api.handleResponse(response) as LibrariesResponse;
|
const result = (await (window as any).api.handleResponse(
|
||||||
|
response,
|
||||||
|
)) as LibrariesResponse;
|
||||||
libraries = result.data;
|
libraries = result.data;
|
||||||
renderLibraries();
|
renderLibraries();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, 'Failed to load libraries');
|
(window as any).api.handleError(error, "Failed to load libraries");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render libraries list (replaces SSR content after updates)
|
// Render libraries list (replaces SSR content after updates)
|
||||||
function renderLibraries(): void {
|
function renderLibraries(): void {
|
||||||
const container = document.getElementById('libraries-list');
|
const container = document.getElementById("libraries-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (libraries.length === 0) {
|
if (libraries.length === 0) {
|
||||||
container.innerHTML = '<p style="color: var(--text-secondary)" class="text-center py-8">No libraries yet. Create your first library to get started.</p>';
|
container.innerHTML =
|
||||||
|
'<p style="color: var(--text-secondary)" class="text-center py-8">No libraries yet. Create your first library to get started.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = libraries.map(library =>
|
container.innerHTML = libraries
|
||||||
|
.map(
|
||||||
|
(library) =>
|
||||||
'<div class="p-4 border rounded-lg" style="background-color: var(--bg-primary); border-color: var(--border)">' +
|
'<div class="p-4 border rounded-lg" style="background-color: var(--bg-primary); border-color: var(--border)">' +
|
||||||
'<div class="flex justify-between items-start mb-2">' +
|
'<div class="flex justify-between items-start mb-2">' +
|
||||||
'<div>' +
|
"<div>" +
|
||||||
`<h4 class="font-semibold" style="color: var(--text-primary)">${escapeHtmlLocal(library.name)}</h4>` +
|
`<h4 class="font-semibold" style="color: var(--text-primary)">${escapeHtmlLocal(library.name)}</h4>` +
|
||||||
(library.description ? `<p class="text-sm" style="color: var(--text-secondary)">${escapeHtmlLocal(library.description)}</p>` : '') +
|
(library.description
|
||||||
|
? `<p class="text-sm" style="color: var(--text-secondary)">${escapeHtmlLocal(library.description)}</p>`
|
||||||
|
: "") +
|
||||||
`<span class="inline-block px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">${escapeHtmlLocal(library.type_name)}</span>` +
|
`<span class="inline-block px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">${escapeHtmlLocal(library.type_name)}</span>` +
|
||||||
'</div>' +
|
"</div>" +
|
||||||
'<div class="flex space-x-2">' +
|
'<div class="flex space-x-2">' +
|
||||||
`<button data-library-id="${library.id}" data-action="show-folders" class="text-xs px-2 py-1 rounded" style="background-color: var(--bg-secondary); color: var(--text-primary)">Folders</button>` +
|
`<button data-library-id="${library.id}" data-action="show-folders" class="text-xs px-2 py-1 rounded" style="background-color: var(--bg-secondary); color: var(--text-primary)">Folders</button>` +
|
||||||
`<button data-library-id="${library.id}" data-action="edit" class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary)">Edit</button>` +
|
`<button data-library-id="${library.id}" data-action="edit" class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary)">Edit</button>` +
|
||||||
`<button data-library-id="${library.id}" data-action="delete" class="text-xs px-2 py-1 rounded text-red-500">Delete</button>` +
|
`<button data-library-id="${library.id}" data-action="delete" class="text-xs px-2 py-1 rounded text-red-500">Delete</button>` +
|
||||||
'</div>' +
|
"</div>" +
|
||||||
'</div>' +
|
"</div>" +
|
||||||
`<div id="library-folders-${library.id}" class="hidden mt-3 space-y-2"></div>` +
|
`<div id="library-folders-${library.id}" class="hidden mt-3 space-y-2"></div>` +
|
||||||
'</div>'
|
"</div>",
|
||||||
).join('');
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user's visible libraries for visibility management
|
// Load user's visible libraries for visibility management
|
||||||
async function loadUserVisibility(): Promise<void> {
|
async function loadUserVisibility(): Promise<void> {
|
||||||
const select = document.getElementById('user-select') as HTMLSelectElement;
|
const select = document.getElementById("user-select") as HTMLSelectElement;
|
||||||
const userId = select?.value;
|
const userId = select?.value;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
const container = document.getElementById('user-libraries');
|
const container = document.getElementById("user-libraries");
|
||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML = '<p style="color: var(--text-secondary)">Please select a user</p>';
|
container.innerHTML =
|
||||||
|
'<p style="color: var(--text-secondary)">Please select a user</p>';
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.get('/libraries/visible');
|
const response = await (window as any).api.get("/libraries/visible");
|
||||||
const visibleLibraries = await (window as any).api.handleResponse(response) as Library[];
|
const visibleLibraries = (await (window as any).api.handleResponse(
|
||||||
const container = document.getElementById('user-libraries');
|
response,
|
||||||
|
)) as Library[];
|
||||||
|
const container = document.getElementById("user-libraries");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const visibleIds = new Set(visibleLibraries.map((lib: Library) => lib.id));
|
const visibleIds = new Set(visibleLibraries.map((lib: Library) => lib.id));
|
||||||
|
|
||||||
container.innerHTML = libraries.map(library => {
|
container.innerHTML = libraries
|
||||||
|
.map((library) => {
|
||||||
const isVisible = visibleIds.has(library.id);
|
const isVisible = visibleIds.has(library.id);
|
||||||
return '<label class="flex items-center space-x-3 p-2 rounded" style="background-color: var(--bg-primary);">' +
|
return (
|
||||||
`<input type="checkbox" ${isVisible ? 'checked' : ''} ` +
|
'<label class="flex items-center space-x-3 p-2 rounded" style="background-color: var(--bg-primary);">' +
|
||||||
|
`<input type="checkbox" ${isVisible ? "checked" : ""} ` +
|
||||||
`data-user-id="${userId}" data-library-id="${library.id}" ` +
|
`data-user-id="${userId}" data-library-id="${library.id}" ` +
|
||||||
`onchange="setLibraryVisibility('${userId}', '${library.id}', this.checked)" ` +
|
`onchange="setLibraryVisibility('${userId}', '${library.id}', this.checked)" ` +
|
||||||
'class="w-4 h-4">' +
|
'class="w-4 h-4">' +
|
||||||
`<span style="color: var(--text-primary)">${escapeHtmlLocal(library.name)} (${escapeHtmlLocal(library.type_name)})</span>` +
|
`<span style="color: var(--text-primary)">${escapeHtmlLocal(library.name)} (${escapeHtmlLocal(library.type_name)})</span>` +
|
||||||
'</label>';
|
"</label>"
|
||||||
}).join('');
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, 'Failed to load user libraries');
|
(window as any).api.handleError(error, "Failed to load user libraries");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set library visibility for a user
|
// Set library visibility for a user
|
||||||
async function setLibraryVisibility(userId: string, libraryId: string, isVisible: boolean): Promise<void> {
|
async function setLibraryVisibility(
|
||||||
|
userId: string,
|
||||||
|
libraryId: string,
|
||||||
|
isVisible: boolean,
|
||||||
|
): Promise<void> {
|
||||||
// userId is used in the HTML onchange handler but the API gets user from JWT context
|
// userId is used in the HTML onchange handler but the API gets user from JWT context
|
||||||
console.debug('Setting visibility for user:', userId, 'library:', libraryId, 'visible:', isVisible);
|
console.debug(
|
||||||
|
"Setting visibility for user:",
|
||||||
|
userId,
|
||||||
|
"library:",
|
||||||
|
libraryId,
|
||||||
|
"visible:",
|
||||||
|
isVisible,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.post('/libraries/visibility', {
|
const response = await (window as any).api.post("/libraries/visibility", {
|
||||||
library_id: libraryId,
|
library_id: libraryId,
|
||||||
is_visible: isVisible
|
is_visible: isVisible,
|
||||||
});
|
});
|
||||||
await (window as any).api.handleVoidResponse(response);
|
await (window as any).api.handleVoidResponse(response);
|
||||||
|
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Library visibility updated');
|
(window as any).showToast.success("Library visibility updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh visibility controls
|
// Refresh visibility controls
|
||||||
void loadUserVisibility();
|
void loadUserVisibility();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, 'Failed to update library visibility');
|
(window as any).api.handleError(
|
||||||
|
error,
|
||||||
|
"Failed to update library visibility",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,48 +180,58 @@ async function handleCreateLibrarySubmit(event: Event): Promise<void> {
|
|||||||
const form = event.target as HTMLFormElement;
|
const form = event.target as HTMLFormElement;
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
|
||||||
const libraryId = (document.getElementById('library-id') as HTMLInputElement)?.value;
|
const libraryId = (document.getElementById("library-id") as HTMLInputElement)
|
||||||
|
?.value;
|
||||||
const isEdit = !!libraryId;
|
const isEdit = !!libraryId;
|
||||||
|
|
||||||
const libraryData = {
|
const libraryData = {
|
||||||
name: formData.get('name') as string,
|
name: formData.get("name") as string,
|
||||||
description: formData.get('description') as string,
|
description: formData.get("description") as string,
|
||||||
type: formData.get('type') as string
|
type: formData.get("type") as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = isEdit ? `/libraries/${libraryId}` : '/libraries';
|
const url = isEdit ? `/libraries/${libraryId}` : "/libraries";
|
||||||
const method = isEdit ? 'put' : 'post';
|
const method = isEdit ? "put" : "post";
|
||||||
|
|
||||||
const response = await (window as any).api[method](url, libraryData);
|
const response = await (window as any).api[method](url, libraryData);
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await (window as any).api.handleVoidResponse(response);
|
await (window as any).api.handleVoidResponse(response);
|
||||||
} else {
|
} else {
|
||||||
await (window as any).api.handleResponse(response) as { data: Library };
|
(await (window as any).api.handleResponse(response)) as { data: Library };
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success(isEdit ? 'Library updated successfully' : 'Library created successfully');
|
(window as any).showToast.success(
|
||||||
|
isEdit
|
||||||
|
? "Library updated successfully"
|
||||||
|
: "Library created successfully",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
hideCreateLibraryModal();
|
hideCreateLibraryModal();
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
const libraryIdInput = document.getElementById('library-id') as HTMLInputElement;
|
const libraryIdInput = document.getElementById(
|
||||||
|
"library-id",
|
||||||
|
) as HTMLInputElement;
|
||||||
if (libraryIdInput) {
|
if (libraryIdInput) {
|
||||||
libraryIdInput.value = '';
|
libraryIdInput.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
void reloadLibraries();
|
void reloadLibraries();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, isEdit ? 'Failed to update library' : 'Failed to create library');
|
(window as any).api.handleError(
|
||||||
|
error,
|
||||||
|
isEdit ? "Failed to update library" : "Failed to create library",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete library
|
// Delete library
|
||||||
async function deleteLibrary(libraryId: string): Promise<void> {
|
async function deleteLibrary(libraryId: string): Promise<void> {
|
||||||
const library = libraries.find(l => l.id === libraryId);
|
const library = libraries.find((l) => l.id === libraryId);
|
||||||
if (!library) return;
|
if (!library) return;
|
||||||
|
|
||||||
showDeleteModal(library);
|
showDeleteModal(library);
|
||||||
@@ -204,35 +243,45 @@ async function showLibraryFolders(libraryId: string): Promise<void> {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.get(`/libraries/${libraryId}/folders`);
|
const response = await (window as any).api.get(
|
||||||
const folders = await (window as any).api.handleResponse(response) as LibraryFolder[];
|
`/libraries/${libraryId}/folders`,
|
||||||
|
);
|
||||||
|
const folders = (await (window as any).api.handleResponse(
|
||||||
|
response,
|
||||||
|
)) as LibraryFolder[];
|
||||||
|
|
||||||
container.innerHTML = folders.map((folder: LibraryFolder) =>
|
container.innerHTML = folders
|
||||||
|
.map(
|
||||||
|
(folder: LibraryFolder) =>
|
||||||
'<div class="flex justify-between items-center p-2 rounded" style="background-color: var(--bg-secondary); border-color: var(--border)">' +
|
'<div class="flex justify-between items-center p-2 rounded" style="background-color: var(--bg-secondary); border-color: var(--border)">' +
|
||||||
`<span class="text-sm" style="color: var(--text-primary)">${escapeHtmlLocal(folder.folder_path)}</span>` +
|
`<span class="text-sm" style="color: var(--text-primary)">${escapeHtmlLocal(folder.folder_path)}</span>` +
|
||||||
`<button data-library-id="${libraryId}" data-folder-path="${escapeHtmlLocal(folder.folder_path)}" data-action="remove-folder" ` +
|
`<button data-library-id="${libraryId}" data-folder-path="${escapeHtmlLocal(folder.folder_path)}" data-action="remove-folder" ` +
|
||||||
'class="text-xs text-red-500">Remove</button>' +
|
'class="text-xs text-red-500">Remove</button>' +
|
||||||
'</div>'
|
"</div>",
|
||||||
).join('');
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
container.innerHTML += '<div class="mt-2 flex space-x-2">' +
|
container.innerHTML +=
|
||||||
|
'<div class="mt-2 flex space-x-2">' +
|
||||||
`<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
|
`<input type="text" id="folder-path-${libraryId}" placeholder="Add folder path" ` +
|
||||||
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
|
'class="flex-1 px-2 py-1 text-sm border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">' +
|
||||||
`<button data-action="browse-folder" data-input-id="folder-path-${libraryId}" ` +
|
`<button data-action="browse-folder" data-input-id="folder-path-${libraryId}" ` +
|
||||||
'class="btn-secondary px-2 py-1 text-xs rounded">Browse</button>' +
|
'class="btn-secondary px-2 py-1 text-xs rounded">Browse</button>' +
|
||||||
`<button data-library-id="${libraryId}" data-action="add-folder" ` +
|
`<button data-library-id="${libraryId}" data-action="add-folder" ` +
|
||||||
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
|
'class="btn-primary px-2 py-1 text-xs rounded">Add</button>' +
|
||||||
'</div>';
|
"</div>";
|
||||||
|
|
||||||
container.classList.remove('hidden');
|
container.classList.remove("hidden");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, 'Failed to load folders');
|
(window as any).api.handleError(error, "Failed to load folders");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add library folder
|
// Add library folder
|
||||||
async function addLibraryFolder(libraryId: string): Promise<void> {
|
async function addLibraryFolder(libraryId: string): Promise<void> {
|
||||||
const input = document.getElementById(`folder-path-${libraryId}`) as HTMLInputElement;
|
const input = document.getElementById(
|
||||||
|
`folder-path-${libraryId}`,
|
||||||
|
) as HTMLInputElement;
|
||||||
const folderPath = input?.value.trim();
|
const folderPath = input?.value.trim();
|
||||||
|
|
||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
@@ -240,26 +289,32 @@ async function addLibraryFolder(libraryId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.post(`/libraries/${libraryId}/folders`, {
|
const response = await (window as any).api.post(
|
||||||
folder_path: folderPath
|
`/libraries/${libraryId}/folders`,
|
||||||
});
|
{
|
||||||
|
folder_path: folderPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
await (window as any).api.handleVoidResponse(response);
|
await (window as any).api.handleVoidResponse(response);
|
||||||
|
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Folder added successfully');
|
(window as any).showToast.success("Folder added successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = '';
|
input.value = "";
|
||||||
}
|
}
|
||||||
void showLibraryFolders(libraryId); // Refresh
|
void showLibraryFolders(libraryId); // Refresh
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, 'Failed to add folder');
|
(window as any).api.handleError(error, "Failed to add folder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove library folder
|
// Remove library folder
|
||||||
async function removeLibraryFolder(libraryId: string, folderPath: string): Promise<void> {
|
async function removeLibraryFolder(
|
||||||
|
libraryId: string,
|
||||||
|
folderPath: string,
|
||||||
|
): Promise<void> {
|
||||||
if (!confirm(`Remove folder "${folderPath}" from the library?`)) {
|
if (!confirm(`Remove folder "${folderPath}" from the library?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -267,47 +322,53 @@ async function removeLibraryFolder(libraryId: string, folderPath: string): Promi
|
|||||||
try {
|
try {
|
||||||
const response = await (window as any).api.delete(
|
const response = await (window as any).api.delete(
|
||||||
`/libraries/${libraryId}/folders`,
|
`/libraries/${libraryId}/folders`,
|
||||||
{ folder_path: folderPath }
|
{ folder_path: folderPath },
|
||||||
);
|
);
|
||||||
await (window as any).api.handleVoidResponse(response);
|
await (window as any).api.handleVoidResponse(response);
|
||||||
|
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Folder removed successfully');
|
(window as any).showToast.success("Folder removed successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
void showLibraryFolders(libraryId); // Refresh
|
void showLibraryFolders(libraryId); // Refresh
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, 'Failed to remove folder');
|
(window as any).api.handleError(error, "Failed to remove folder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit library (placeholder - opens modal or navigates to edit page)
|
// Edit library (placeholder - opens modal or navigates to edit page)
|
||||||
function editLibrary(libraryId: string): void {
|
function editLibrary(libraryId: string): void {
|
||||||
const library = libraries.find(l => l.id === libraryId);
|
const library = libraries.find((l) => l.id === libraryId);
|
||||||
if (!library) {
|
if (!library) {
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Library not found');
|
(window as any).showToast.error("Library not found");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = document.getElementById('create-library-form') as HTMLFormElement;
|
const form = document.getElementById(
|
||||||
|
"create-library-form",
|
||||||
|
) as HTMLFormElement;
|
||||||
if (form) {
|
if (form) {
|
||||||
const nameInput = form.querySelector('[name="name"]') as HTMLInputElement;
|
const nameInput = form.querySelector('[name="name"]') as HTMLInputElement;
|
||||||
const descInput = form.querySelector('[name="description"]') as HTMLTextAreaElement;
|
const descInput = form.querySelector(
|
||||||
|
'[name="description"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
const typeInput = form.querySelector('[name="type"]') as HTMLSelectElement;
|
const typeInput = form.querySelector('[name="type"]') as HTMLSelectElement;
|
||||||
|
|
||||||
if (nameInput) nameInput.value = library.name;
|
if (nameInput) nameInput.value = library.name;
|
||||||
if (descInput) descInput.value = library.description || '';
|
if (descInput) descInput.value = library.description || "";
|
||||||
if (typeInput) typeInput.value = library.type_name;
|
if (typeInput) typeInput.value = library.type_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalTitle = document.querySelector('#create-library-modal h2');
|
const modalTitle = document.querySelector("#create-library-modal h2");
|
||||||
if (modalTitle) {
|
if (modalTitle) {
|
||||||
modalTitle.textContent = 'Edit Library';
|
modalTitle.textContent = "Edit Library";
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryIdInput = document.getElementById('library-id') as HTMLInputElement;
|
const libraryIdInput = document.getElementById(
|
||||||
|
"library-id",
|
||||||
|
) as HTMLInputElement;
|
||||||
if (libraryIdInput) {
|
if (libraryIdInput) {
|
||||||
libraryIdInput.value = libraryId;
|
libraryIdInput.value = libraryId;
|
||||||
}
|
}
|
||||||
@@ -317,21 +378,21 @@ function editLibrary(libraryId: string): void {
|
|||||||
|
|
||||||
// Modal controls
|
// Modal controls
|
||||||
function showCreateLibraryModal(): void {
|
function showCreateLibraryModal(): void {
|
||||||
const modal = document.getElementById('create-library-modal') as HTMLElement;
|
const modal = document.getElementById("create-library-modal") as HTMLElement;
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove("hidden");
|
||||||
|
|
||||||
const modalTitle = document.querySelector('#create-library-modal h2');
|
const modalTitle = document.querySelector("#create-library-modal h2");
|
||||||
if (modalTitle) {
|
if (modalTitle) {
|
||||||
modalTitle.textContent = 'Create Library';
|
modalTitle.textContent = "Create Library";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCreateLibraryModal(): void {
|
function hideCreateLibraryModal(): void {
|
||||||
const modal = document.getElementById('create-library-modal') as HTMLElement;
|
const modal = document.getElementById("create-library-modal") as HTMLElement;
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.add('hidden');
|
modal.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,8 +402,10 @@ let libraryToDelete: Library | null = null;
|
|||||||
function showDeleteModal(library: Library): void {
|
function showDeleteModal(library: Library): void {
|
||||||
libraryToDelete = library;
|
libraryToDelete = library;
|
||||||
|
|
||||||
const modal = document.getElementById('delete-library-modal') as HTMLElement;
|
const modal = document.getElementById("delete-library-modal") as HTMLElement;
|
||||||
const content = document.getElementById('delete-modal-content') as HTMLElement;
|
const content = document.getElementById(
|
||||||
|
"delete-modal-content",
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
if (modal && content) {
|
if (modal && content) {
|
||||||
const message = `Are you sure you want to delete "<strong>${escapeHtmlLocal(library.name)}</strong>"?
|
const message = `Are you sure you want to delete "<strong>${escapeHtmlLocal(library.name)}</strong>"?
|
||||||
@@ -356,15 +419,15 @@ This will remove:
|
|||||||
|
|
||||||
This action cannot be undone.`;
|
This action cannot be undone.`;
|
||||||
|
|
||||||
content.innerHTML = message.replace(/\n/g, '<br>');
|
content.innerHTML = message.replace(/\n/g, "<br>");
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideDeleteModal(): void {
|
function hideDeleteModal(): void {
|
||||||
const modal = document.getElementById('delete-library-modal') as HTMLElement;
|
const modal = document.getElementById("delete-library-modal") as HTMLElement;
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.add('hidden');
|
modal.classList.add("hidden");
|
||||||
}
|
}
|
||||||
libraryToDelete = null;
|
libraryToDelete = null;
|
||||||
}
|
}
|
||||||
@@ -376,27 +439,31 @@ async function confirmDeleteLibrary(): Promise<void> {
|
|||||||
hideDeleteModal();
|
hideDeleteModal();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.delete(`/libraries/${libraryId}`);
|
const response = await (window as any).api.delete(
|
||||||
|
`/libraries/${libraryId}`,
|
||||||
|
);
|
||||||
await (window as any).api.handleVoidResponse(response);
|
await (window as any).api.handleVoidResponse(response);
|
||||||
|
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Library deleted successfully');
|
(window as any).showToast.success("Library deleted successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
await reloadLibraries();
|
await reloadLibraries();
|
||||||
|
|
||||||
const libraryIdInput = document.getElementById('library-id') as HTMLInputElement | null;
|
const libraryIdInput = document.getElementById(
|
||||||
|
"library-id",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
if (libraryIdInput) {
|
if (libraryIdInput) {
|
||||||
libraryIdInput.value = '';
|
libraryIdInput.value = "";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, 'Failed to delete library');
|
(window as any).api.handleError(error, "Failed to delete library");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local escape HTML helper
|
// Local escape HTML helper
|
||||||
function escapeHtmlLocal(text: string): string {
|
function escapeHtmlLocal(text: string): string {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement("div");
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
@@ -404,31 +471,31 @@ function escapeHtmlLocal(text: string): string {
|
|||||||
// Event delegation for handling dynamic button clicks
|
// Event delegation for handling dynamic button clicks
|
||||||
function handleLibraryListClick(event: Event): void {
|
function handleLibraryListClick(event: Event): void {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const button = target.closest('button') as HTMLElement;
|
const button = target.closest("button") as HTMLElement;
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
const action = button.dataset.action;
|
const action = button.dataset.action;
|
||||||
const libraryId = button.dataset.libraryId;
|
const libraryId = button.dataset.libraryId;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'show-folders':
|
case "show-folders":
|
||||||
if (libraryId) showLibraryFolders(libraryId);
|
if (libraryId) showLibraryFolders(libraryId);
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case "delete":
|
||||||
if (libraryId) deleteLibrary(libraryId);
|
if (libraryId) deleteLibrary(libraryId);
|
||||||
break;
|
break;
|
||||||
case 'edit':
|
case "edit":
|
||||||
if (libraryId) editLibrary(libraryId);
|
if (libraryId) editLibrary(libraryId);
|
||||||
break;
|
break;
|
||||||
case 'add-folder':
|
case "add-folder":
|
||||||
if (libraryId) addLibraryFolder(libraryId);
|
if (libraryId) addLibraryFolder(libraryId);
|
||||||
break;
|
break;
|
||||||
case 'remove-folder':
|
case "remove-folder":
|
||||||
if (libraryId && button.dataset.folderPath) {
|
if (libraryId && button.dataset.folderPath) {
|
||||||
removeLibraryFolder(libraryId, button.dataset.folderPath);
|
removeLibraryFolder(libraryId, button.dataset.folderPath);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'browse-folder':
|
case "browse-folder":
|
||||||
if (button.dataset.inputId) showFolderBrowser(button.dataset.inputId);
|
if (button.dataset.inputId) showFolderBrowser(button.dataset.inputId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -436,27 +503,27 @@ function handleLibraryListClick(event: Event): void {
|
|||||||
|
|
||||||
function handleFolderBrowserClick(event: Event): void {
|
function handleFolderBrowserClick(event: Event): void {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const button = target.closest('button') as HTMLElement;
|
const button = target.closest("button") as HTMLElement;
|
||||||
const div = target.closest('div[data-action]') as HTMLElement;
|
const div = target.closest("div[data-action]") as HTMLElement;
|
||||||
|
|
||||||
if (button) {
|
if (button) {
|
||||||
const action = button.dataset.action;
|
const action = button.dataset.action;
|
||||||
const path = button.dataset.path;
|
const path = button.dataset.path;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'browse-parent':
|
case "browse-parent":
|
||||||
if (path) navigateFolderBrowser(path);
|
if (path) navigateFolderBrowser(path);
|
||||||
break;
|
break;
|
||||||
case 'browse-cancel':
|
case "browse-cancel":
|
||||||
hideFolderBrowser();
|
hideFolderBrowser();
|
||||||
break;
|
break;
|
||||||
case 'browse-select':
|
case "browse-select":
|
||||||
if (path) selectBrowseFolder(path);
|
if (path) selectBrowseFolder(path);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (div && div.dataset.action === 'browse-navigate') {
|
if (div && div.dataset.action === "browse-navigate") {
|
||||||
const path = div.dataset.path;
|
const path = div.dataset.path;
|
||||||
if (path) navigateFolderBrowser(path);
|
if (path) navigateFolderBrowser(path);
|
||||||
}
|
}
|
||||||
@@ -464,39 +531,39 @@ function handleFolderBrowserClick(event: Event): void {
|
|||||||
|
|
||||||
function handleGlobalClick(event: Event): void {
|
function handleGlobalClick(event: Event): void {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const button = target.closest('button') as HTMLElement;
|
const button = target.closest("button") as HTMLElement;
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
const action = button.dataset.action;
|
const action = button.dataset.action;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'show-create-modal':
|
case "show-create-modal":
|
||||||
showCreateLibraryModal();
|
showCreateLibraryModal();
|
||||||
break;
|
break;
|
||||||
case 'hide-create-modal':
|
case "hide-create-modal":
|
||||||
hideCreateLibraryModal();
|
hideCreateLibraryModal();
|
||||||
break;
|
break;
|
||||||
case 'hide-delete-modal':
|
case "hide-delete-modal":
|
||||||
hideDeleteModal();
|
hideDeleteModal();
|
||||||
break;
|
break;
|
||||||
case 'confirm-delete':
|
case "confirm-delete":
|
||||||
void confirmDeleteLibrary();
|
void confirmDeleteLibrary();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder browser state
|
// Folder browser state
|
||||||
let currentBrowsePath = '';
|
let currentBrowsePath = "";
|
||||||
let currentBrowseInputId = '';
|
let currentBrowseInputId = "";
|
||||||
|
|
||||||
// Show folder browser modal
|
// Show folder browser modal
|
||||||
function showFolderBrowser(inputId: string): void {
|
function showFolderBrowser(inputId: string): void {
|
||||||
currentBrowseInputId = inputId;
|
currentBrowseInputId = inputId;
|
||||||
currentBrowsePath = '/';
|
currentBrowsePath = "/";
|
||||||
|
|
||||||
const modal = document.getElementById('folder-browser-modal') as HTMLElement;
|
const modal = document.getElementById("folder-browser-modal") as HTMLElement;
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove("hidden");
|
||||||
void loadBrowseDirectories(currentBrowsePath);
|
void loadBrowseDirectories(currentBrowsePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,8 +571,10 @@ function showFolderBrowser(inputId: string): void {
|
|||||||
// Load directories for browsing
|
// Load directories for browsing
|
||||||
async function loadBrowseDirectories(path: string): Promise<void> {
|
async function loadBrowseDirectories(path: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await (window as any).api.get(`/libraries/browse?path=${encodeURIComponent(path)}`);
|
const response = await (window as any).api.get(
|
||||||
const data = await (window as any).api.handleResponse(response) as {
|
`/libraries/browse?path=${encodeURIComponent(path)}`,
|
||||||
|
);
|
||||||
|
const data = (await (window as any).api.handleResponse(response)) as {
|
||||||
current_path: string;
|
current_path: string;
|
||||||
parent_path: string;
|
parent_path: string;
|
||||||
directories: string[];
|
directories: string[];
|
||||||
@@ -514,30 +583,38 @@ async function loadBrowseDirectories(path: string): Promise<void> {
|
|||||||
currentBrowsePath = data.current_path;
|
currentBrowsePath = data.current_path;
|
||||||
renderBrowseDirectories(data);
|
renderBrowseDirectories(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(window as any).api.handleError(error, 'Failed to load directories');
|
(window as any).api.handleError(error, "Failed to load directories");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render browse directories (uses event delegation via data-action attributes)
|
// Render browse directories (uses event delegation via data-action attributes)
|
||||||
function renderBrowseDirectories(data: { current_path: string; parent_path: string; directories: string[] }): void {
|
function renderBrowseDirectories(data: {
|
||||||
const container = document.getElementById('folder-browser-content');
|
current_path: string;
|
||||||
|
parent_path: string;
|
||||||
|
directories: string[];
|
||||||
|
}): void {
|
||||||
|
const container = document.getElementById("folder-browser-content");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
${data.parent_path ?
|
${
|
||||||
`<button type="button" data-action="browse-parent" data-path="${escapeHtmlLocal(data.parent_path)}" class="btn-secondary px-3 py-1 rounded">↑ Parent</button>`
|
data.parent_path
|
||||||
: ''}
|
? `<button type="button" data-action="browse-parent" data-path="${escapeHtmlLocal(data.parent_path)}" class="btn-secondary px-3 py-1 rounded">↑ Parent</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
<span class="text-sm" style="color: var(--text-secondary)">${escapeHtmlLocal(data.current_path)}</span>
|
<span class="text-sm" style="color: var(--text-secondary)">${escapeHtmlLocal(data.current_path)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-64 overflow-y-auto space-y-1">
|
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (data.directories.length === 0) {
|
if (data.directories.length === 0) {
|
||||||
html += '<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
|
html +=
|
||||||
|
'<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
|
||||||
} else {
|
} else {
|
||||||
data.directories.forEach(dir => {
|
data.directories.forEach((dir) => {
|
||||||
const fullPath = data.current_path === '/' ? `/${dir}` : `${data.current_path}/${dir}`;
|
const fullPath =
|
||||||
|
data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`;
|
||||||
html += `
|
html += `
|
||||||
<div class="p-2 rounded cursor-pointer hover:opacity-80"
|
<div class="p-2 rounded cursor-pointer hover:opacity-80"
|
||||||
style="background-color: var(--bg-secondary); color: var(--text-primary)"
|
style="background-color: var(--bg-secondary); color: var(--text-primary)"
|
||||||
@@ -567,7 +644,9 @@ function navigateFolderBrowser(path: string): void {
|
|||||||
|
|
||||||
// Select folder and close browser
|
// Select folder and close browser
|
||||||
function selectBrowseFolder(path: string): void {
|
function selectBrowseFolder(path: string): void {
|
||||||
const input = document.getElementById(currentBrowseInputId) as HTMLInputElement;
|
const input = document.getElementById(
|
||||||
|
currentBrowseInputId,
|
||||||
|
) as HTMLInputElement;
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = path;
|
input.value = path;
|
||||||
}
|
}
|
||||||
@@ -576,31 +655,31 @@ function selectBrowseFolder(path: string): void {
|
|||||||
|
|
||||||
// Hide folder browser modal
|
// Hide folder browser modal
|
||||||
function hideFolderBrowser(): void {
|
function hideFolderBrowser(): void {
|
||||||
const modal = document.getElementById('folder-browser-modal') as HTMLElement;
|
const modal = document.getElementById("folder-browser-modal") as HTMLElement;
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.add('hidden');
|
modal.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize page
|
// Initialize page
|
||||||
function initializeLibraryAdmin(): void {
|
function initializeLibraryAdmin(): void {
|
||||||
// Setup event listeners
|
// Setup event listeners
|
||||||
const librariesList = document.getElementById('libraries-list');
|
const librariesList = document.getElementById("libraries-list");
|
||||||
if (librariesList) {
|
if (librariesList) {
|
||||||
librariesList.addEventListener('click', handleLibraryListClick);
|
librariesList.addEventListener("click", handleLibraryListClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderBrowserModal = document.getElementById('folder-browser-modal');
|
const folderBrowserModal = document.getElementById("folder-browser-modal");
|
||||||
if (folderBrowserModal) {
|
if (folderBrowserModal) {
|
||||||
folderBrowserModal.addEventListener('click', handleFolderBrowserClick);
|
folderBrowserModal.addEventListener("click", handleFolderBrowserClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', handleGlobalClick);
|
document.addEventListener("click", handleGlobalClick);
|
||||||
|
|
||||||
// Setup form submission
|
// Setup form submission
|
||||||
const createLibraryForm = document.getElementById('create-library-form');
|
const createLibraryForm = document.getElementById("create-library-form");
|
||||||
if (createLibraryForm) {
|
if (createLibraryForm) {
|
||||||
createLibraryForm.addEventListener('submit', handleCreateLibrarySubmit);
|
createLibraryForm.addEventListener("submit", handleCreateLibrarySubmit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load libraries from API on page load
|
// Load libraries from API on page load
|
||||||
@@ -625,8 +704,8 @@ function initializeLibraryAdmin(): void {
|
|||||||
(window as any).confirmDeleteLibrary = confirmDeleteLibrary;
|
(window as any).confirmDeleteLibrary = confirmDeleteLibrary;
|
||||||
|
|
||||||
// Initialize on DOM ready
|
// Initialize on DOM ready
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', initializeLibraryAdmin);
|
document.addEventListener("DOMContentLoaded", initializeLibraryAdmin);
|
||||||
} else {
|
} else {
|
||||||
initializeLibraryAdmin();
|
initializeLibraryAdmin();
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-44
@@ -1,10 +1,10 @@
|
|||||||
async function loadUnlinkedBooks(): Promise<void> {
|
async function loadUnlinkedBooks(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/sync/unlinked-books', {
|
const response = await fetch("/api/sync/unlinked-books", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -12,20 +12,23 @@ async function loadUnlinkedBooks(): Promise<void> {
|
|||||||
renderUnlinkedBooks(data.unlinked || []);
|
renderUnlinkedBooks(data.unlinked || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load unlinked books:', error);
|
console.error("Failed to load unlinked books:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
||||||
const container = document.getElementById('unlinked-books-list');
|
const container = document.getElementById("unlinked-books-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (books.length === 0) {
|
if (books.length === 0) {
|
||||||
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>';
|
container.innerHTML =
|
||||||
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = books.map(book => `
|
container.innerHTML = books
|
||||||
|
.map(
|
||||||
|
(book) => `
|
||||||
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -38,10 +41,15 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
|||||||
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
|
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${book.potential_matches && book.potential_matches.length > 0 ? `
|
${
|
||||||
|
book.potential_matches && book.potential_matches.length > 0
|
||||||
|
? `
|
||||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border)">
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border)">
|
||||||
<p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p>
|
<p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p>
|
||||||
${book.potential_matches.slice(0, 3).map(match => `
|
${book.potential_matches
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(
|
||||||
|
(match) => `
|
||||||
<div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)">
|
<div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
|
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
|
||||||
@@ -49,84 +57,98 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
|||||||
</div>
|
</div>
|
||||||
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
|
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
|
async function linkBook(
|
||||||
const token = localStorage.getItem('token');
|
progressId: string,
|
||||||
|
mediaItemId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/sync/link-book', {
|
const response = await fetch("/api/sync/link-book", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ progress_id: progressId, media_item_id: mediaItemId })
|
body: JSON.stringify({
|
||||||
|
progress_id: progressId,
|
||||||
|
media_item_id: mediaItemId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Book linked successfully');
|
(window as any).showToast.success("Book linked successfully");
|
||||||
}
|
}
|
||||||
loadUnlinkedBooks();
|
loadUnlinkedBooks();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error(error.error || 'Failed to link book');
|
(window as any).showToast.error(error.error || "Failed to link book");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to link book:', error);
|
console.error("Failed to link book:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to link book');
|
(window as any).showToast.error("Failed to link book");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoLinkBooks(): Promise<void> {
|
async function autoLinkBooks(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
if (!confirm('Auto-link all books with high confidence matches?')) return;
|
if (!confirm("Auto-link all books with high confidence matches?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/sync/auto-link', {
|
const response = await fetch("/api/sync/auto-link", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ confidence_threshold: 0.9 })
|
body: JSON.stringify({ confidence_threshold: 0.9 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success(`Auto-linked ${data.linked_count || 0} books`);
|
(window as any).showToast.success(
|
||||||
|
`Auto-linked ${data.linked_count || 0} books`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
loadUnlinkedBooks();
|
loadUnlinkedBooks();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to auto-link:', error);
|
console.error("Failed to auto-link:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to auto-link books');
|
(window as any).showToast.error("Failed to auto-link books");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSuggestions(progressId: string): Promise<void> {
|
async function getSuggestions(progressId: string): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/sync/suggestions/${progressId}`, {
|
const response = await fetch(`/api/sync/suggestions/${progressId}`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -134,13 +156,16 @@ async function getSuggestions(progressId: string): Promise<void> {
|
|||||||
showSuggestionsModal(progressId, suggestions);
|
showSuggestionsModal(progressId, suggestions);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get suggestions:', error);
|
console.error("Failed to get suggestions:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void {
|
function showSuggestionsModal(
|
||||||
const modal = document.getElementById('match-modal');
|
progressId: string,
|
||||||
const content = document.getElementById('match-modal-content');
|
suggestions: PotentialMatchData[],
|
||||||
|
): void {
|
||||||
|
const modal = document.getElementById("match-modal");
|
||||||
|
const content = document.getElementById("match-modal-content");
|
||||||
|
|
||||||
if (!modal || !content) return;
|
if (!modal || !content) return;
|
||||||
|
|
||||||
@@ -148,7 +173,9 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat
|
|||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3>
|
<h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
${suggestions.map(s => `
|
${suggestions
|
||||||
|
.map(
|
||||||
|
(s) => `
|
||||||
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
|
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
|
||||||
style="background-color: var(--bg-primary); border-color: var(--border)"
|
style="background-color: var(--bg-primary); border-color: var(--border)"
|
||||||
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
|
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
|
||||||
@@ -156,19 +183,21 @@ function showSuggestionsModal(progressId: string, suggestions: PotentialMatchDat
|
|||||||
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
|
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
|
||||||
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
|
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
</div>
|
</div>
|
||||||
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
|
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideMatchModal(): void {
|
function hideMatchModal(): void {
|
||||||
const modal = document.getElementById('match-modal');
|
const modal = document.getElementById("match-modal");
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.add('hidden');
|
modal.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,43 +44,45 @@ function updateRequirementStatus(elementId: string, passed: boolean): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = element.querySelector('.requirement-icon');
|
const icon = element.querySelector(".requirement-icon");
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passed) {
|
if (passed) {
|
||||||
icon.textContent = '✓';
|
icon.textContent = "✓";
|
||||||
icon.className = 'requirement-icon text-green-500';
|
icon.className = "requirement-icon text-green-500";
|
||||||
element.style.color = 'var(--text-primary)';
|
element.style.color = "var(--text-primary)";
|
||||||
} else {
|
} else {
|
||||||
icon.textContent = '○';
|
icon.textContent = "○";
|
||||||
icon.className = 'requirement-icon';
|
icon.className = "requirement-icon";
|
||||||
element.style.color = 'var(--text-secondary)';
|
element.style.color = "var(--text-secondary)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSubmitButton(allPassed: boolean): void {
|
function updateSubmitButton(allPassed: boolean): void {
|
||||||
const button = document.getElementById('register-btn') as HTMLButtonElement;
|
const button = document.getElementById("register-btn") as HTMLButtonElement;
|
||||||
if (!button) {
|
if (!button) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allPassed) {
|
if (allPassed) {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.classList.remove('opacity-50', 'cursor-not-allowed');
|
button.classList.remove("opacity-50", "cursor-not-allowed");
|
||||||
} else {
|
} else {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.classList.add('opacity-50', 'cursor-not-allowed');
|
button.classList.add("opacity-50", "cursor-not-allowed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main validation orchestrator
|
// Main validation orchestrator
|
||||||
function validateAll(): void {
|
function validateAll(): void {
|
||||||
const passwordField = document.getElementById('password') as HTMLInputElement;
|
const passwordField = document.getElementById("password") as HTMLInputElement;
|
||||||
const confirmField = document.getElementById('confirm-password') as HTMLInputElement;
|
const confirmField = document.getElementById(
|
||||||
const usernameField = document.getElementById('username') as HTMLInputElement;
|
"confirm-password",
|
||||||
const emailField = document.getElementById('email') as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
const usernameField = document.getElementById("username") as HTMLInputElement;
|
||||||
|
const emailField = document.getElementById("email") as HTMLInputElement;
|
||||||
|
|
||||||
if (!passwordField || !confirmField || !usernameField || !emailField) {
|
if (!passwordField || !confirmField || !usernameField || !emailField) {
|
||||||
return;
|
return;
|
||||||
@@ -102,16 +104,23 @@ function validateAll(): void {
|
|||||||
const hasEmailAddr = hasEmail(email);
|
const hasEmailAddr = hasEmail(email);
|
||||||
|
|
||||||
// Update requirement indicators
|
// Update requirement indicators
|
||||||
updateRequirementStatus('req-length', hasLen);
|
updateRequirementStatus("req-length", hasLen);
|
||||||
updateRequirementStatus('req-upper', hasUpper);
|
updateRequirementStatus("req-upper", hasUpper);
|
||||||
updateRequirementStatus('req-lower', hasLower);
|
updateRequirementStatus("req-lower", hasLower);
|
||||||
updateRequirementStatus('req-number', hasNum);
|
updateRequirementStatus("req-number", hasNum);
|
||||||
updateRequirementStatus('req-special', hasSpecial);
|
updateRequirementStatus("req-special", hasSpecial);
|
||||||
updateRequirementStatus('req-match', doMatch);
|
updateRequirementStatus("req-match", doMatch);
|
||||||
|
|
||||||
// Enable/disable submit button
|
// Enable/disable submit button
|
||||||
const allPassed = hasLen && hasUpper && hasLower && hasNum &&
|
const allPassed =
|
||||||
hasSpecial && doMatch && hasUser && hasEmailAddr;
|
hasLen &&
|
||||||
|
hasUpper &&
|
||||||
|
hasLower &&
|
||||||
|
hasNum &&
|
||||||
|
hasSpecial &&
|
||||||
|
doMatch &&
|
||||||
|
hasUser &&
|
||||||
|
hasEmailAddr;
|
||||||
updateSubmitButton(allPassed);
|
updateSubmitButton(allPassed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,27 +156,29 @@ function onEmailInput(): void {
|
|||||||
|
|
||||||
// Initialization
|
// Initialization
|
||||||
function initPasswordValidation(): void {
|
function initPasswordValidation(): void {
|
||||||
const passwordField = document.getElementById('password') as HTMLInputElement;
|
const passwordField = document.getElementById("password") as HTMLInputElement;
|
||||||
const confirmField = document.getElementById('confirm-password') as HTMLInputElement;
|
const confirmField = document.getElementById(
|
||||||
const usernameField = document.getElementById('username') as HTMLInputElement;
|
"confirm-password",
|
||||||
const emailField = document.getElementById('email') as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
const usernameField = document.getElementById("username") as HTMLInputElement;
|
||||||
|
const emailField = document.getElementById("email") as HTMLInputElement;
|
||||||
|
|
||||||
if (!passwordField || !confirmField || !usernameField || !emailField) {
|
if (!passwordField || !confirmField || !usernameField || !emailField) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add passive event listeners - don't prevent default, don't block password managers
|
// Add passive event listeners - don't prevent default, don't block password managers
|
||||||
passwordField.addEventListener('input', onPasswordInput, { passive: true });
|
passwordField.addEventListener("input", onPasswordInput, { passive: true });
|
||||||
passwordField.addEventListener('paste', onPasswordInput, { passive: true });
|
passwordField.addEventListener("paste", onPasswordInput, { passive: true });
|
||||||
|
|
||||||
confirmField.addEventListener('input', onConfirmInput, { passive: true });
|
confirmField.addEventListener("input", onConfirmInput, { passive: true });
|
||||||
confirmField.addEventListener('paste', onConfirmInput, { passive: true });
|
confirmField.addEventListener("paste", onConfirmInput, { passive: true });
|
||||||
|
|
||||||
usernameField.addEventListener('input', onUsernameInput, { passive: true });
|
usernameField.addEventListener("input", onUsernameInput, { passive: true });
|
||||||
usernameField.addEventListener('paste', onUsernameInput, { passive: true });
|
usernameField.addEventListener("paste", onUsernameInput, { passive: true });
|
||||||
|
|
||||||
emailField.addEventListener('input', onEmailInput, { passive: true });
|
emailField.addEventListener("input", onEmailInput, { passive: true });
|
||||||
emailField.addEventListener('paste', onEmailInput, { passive: true });
|
emailField.addEventListener("paste", onEmailInput, { passive: true });
|
||||||
|
|
||||||
// Initial validation
|
// Initial validation
|
||||||
validateAll();
|
validateAll();
|
||||||
|
|||||||
+51
-46
@@ -1,10 +1,10 @@
|
|||||||
async function refreshQueue(): Promise<void> {
|
async function refreshQueue(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/queue/all', {
|
const response = await fetch("/api/queue/all", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -12,159 +12,164 @@ async function refreshQueue(): Promise<void> {
|
|||||||
renderQueueItems(data.items || []);
|
renderQueueItems(data.items || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh queue:', error);
|
console.error("Failed to refresh queue:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processPendingItems(): Promise<void> {
|
async function processPendingItems(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/queue/process', {
|
const response = await fetch("/api/queue/process", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Processing queue items');
|
(window as any).showToast.success("Processing queue items");
|
||||||
}
|
}
|
||||||
refreshQueue();
|
refreshQueue();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to process queue:', error);
|
console.error("Failed to process queue:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to process queue');
|
(window as any).showToast.error("Failed to process queue");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearFailedItems(): Promise<void> {
|
async function clearFailedItems(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
if (!confirm('Are you sure you want to clear all failed items?')) return;
|
if (!confirm("Are you sure you want to clear all failed items?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/queue/failed', {
|
const response = await fetch("/api/queue/failed", {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Failed items cleared');
|
(window as any).showToast.success("Failed items cleared");
|
||||||
}
|
}
|
||||||
refreshQueue();
|
refreshQueue();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear failed items:', error);
|
console.error("Failed to clear failed items:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to clear items');
|
(window as any).showToast.error("Failed to clear items");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearAllItems(): Promise<void> {
|
async function clearAllItems(): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
if (!confirm('Are you sure you want to clear all queue items?')) return;
|
if (!confirm("Are you sure you want to clear all queue items?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/queue/all', {
|
const response = await fetch("/api/queue/all", {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Queue cleared');
|
(window as any).showToast.success("Queue cleared");
|
||||||
}
|
}
|
||||||
refreshQueue();
|
refreshQueue();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear queue:', error);
|
console.error("Failed to clear queue:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to clear queue');
|
(window as any).showToast.error("Failed to clear queue");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryQueueItem(itemId: string): Promise<void> {
|
async function retryQueueItem(itemId: string): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/queue/items/${itemId}/retry`, {
|
const response = await fetch(`/api/queue/items/${itemId}/retry`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Item queued for retry');
|
(window as any).showToast.success("Item queued for retry");
|
||||||
}
|
}
|
||||||
refreshQueue();
|
refreshQueue();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to retry item:', error);
|
console.error("Failed to retry item:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to retry item');
|
(window as any).showToast.error("Failed to retry item");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteQueueItem(itemId: string): Promise<void> {
|
async function deleteQueueItem(itemId: string): Promise<void> {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/queue/items/${itemId}`, {
|
const response = await fetch(`/api/queue/items/${itemId}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if ((window as any).showToast?.success) {
|
if ((window as any).showToast?.success) {
|
||||||
(window as any).showToast.success('Item deleted');
|
(window as any).showToast.success("Item deleted");
|
||||||
}
|
}
|
||||||
refreshQueue();
|
refreshQueue();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete item:', error);
|
console.error("Failed to delete item:", error);
|
||||||
if ((window as any).showToast?.error) {
|
if ((window as any).showToast?.error) {
|
||||||
(window as any).showToast.error('Failed to delete item');
|
(window as any).showToast.error("Failed to delete item");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQueueItems(items: QueueItemResponse[]): void {
|
function renderQueueItems(items: QueueItemResponse[]): void {
|
||||||
const container = document.getElementById('queue-items');
|
const container = document.getElementById("queue-items");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
container.innerHTML = '<p class="text-center p-4" style="color: var(--text-secondary)">Queue is empty</p>';
|
container.innerHTML =
|
||||||
|
'<p class="text-center p-4" style="color: var(--text-secondary)">Queue is empty</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = items.map(item => `
|
container.innerHTML = items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
<div class="p-3 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
<div class="p-3 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium" style="color: var(--text-primary)">${item.media_title || 'Unknown'}</p>
|
<p class="font-medium" style="color: var(--text-primary)">${item.media_title || "Unknown"}</p>
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">${item.status} - ${item.sync_type}</p>
|
<p class="text-sm" style="color: var(--text-secondary)">${item.status} - ${item.sync_type}</p>
|
||||||
<p class="text-xs" style="color: var(--text-secondary)">Attempts: ${item.attempts}/${item.max_attempts}</p>
|
<p class="text-xs" style="color: var(--text-secondary)">Attempts: ${item.attempts}/${item.max_attempts}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
${item.status === 'failed' ? `<button onclick="window.retryQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Retry</button>` : ''}
|
${item.status === "failed" ? `<button onclick="window.retryQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Retry</button>` : ""}
|
||||||
<button onclick="window.deleteQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
<button onclick="window.deleteQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${item.error_message ? `<p class="text-xs mt-2" style="color: var(--accent)">${item.error_message}</p>` : ''}
|
${item.error_message ? `<p class="text-xs mt-2" style="color: var(--accent)">${item.error_message}</p>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as any).refreshQueue = refreshQueue;
|
(window as any).refreshQueue = refreshQueue;
|
||||||
|
|||||||
+84
-68
@@ -3,25 +3,31 @@ const SEARCH_DEBOUNCE_MS = 300;
|
|||||||
const SEARCH_MIN_CHARS = 2;
|
const SEARCH_MIN_CHARS = 2;
|
||||||
|
|
||||||
function initializeSearch(): void {
|
function initializeSearch(): void {
|
||||||
const searchInput = document.getElementById('header-search') as HTMLInputElement | null;
|
const searchInput = document.getElementById(
|
||||||
|
"header-search",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
if (!searchInput) {
|
if (!searchInput) {
|
||||||
console.warn('Search input not found');
|
console.warn("Search input not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchInput.addEventListener('input', handleSearchInput);
|
searchInput.addEventListener("input", handleSearchInput);
|
||||||
searchInput.addEventListener('keydown', handleSearchKeydown);
|
searchInput.addEventListener("keydown", handleSearchKeydown);
|
||||||
searchInput.addEventListener('focus', () => {
|
searchInput.addEventListener("focus", () => {
|
||||||
if (searchInput.value.length >= SEARCH_MIN_CHARS) {
|
if (searchInput.value.length >= SEARCH_MIN_CHARS) {
|
||||||
performSearch(searchInput.value);
|
performSearch(searchInput.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('click', (e: MouseEvent) => {
|
document.addEventListener("click", (e: MouseEvent) => {
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById("search-results");
|
||||||
const searchInputEl = document.getElementById('header-search');
|
const searchInputEl = document.getElementById("header-search");
|
||||||
|
|
||||||
if (searchResults && !searchResults.contains(e.target as Node) && e.target !== searchInputEl) {
|
if (
|
||||||
|
searchResults &&
|
||||||
|
!searchResults.contains(e.target as Node) &&
|
||||||
|
e.target !== searchInputEl
|
||||||
|
) {
|
||||||
hideSearchResults();
|
hideSearchResults();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -46,29 +52,29 @@ function handleSearchInput(e: Event): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSearchKeydown(e: KeyboardEvent): void {
|
function handleSearchKeydown(e: KeyboardEvent): void {
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById("search-results");
|
||||||
if (!searchResults || searchResults.classList.contains('hidden')) {
|
if (!searchResults || searchResults.classList.contains("hidden")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = searchResults.querySelectorAll('.search-result-item');
|
const items = searchResults.querySelectorAll(".search-result-item");
|
||||||
const currentIndex = parseInt(searchResults.dataset.selectedIndex || '-1');
|
const currentIndex = parseInt(searchResults.dataset.selectedIndex || "-1");
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
|
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
|
||||||
selectSearchResult(items, nextIndex);
|
selectSearchResult(items, nextIndex);
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prevIndex = Math.max(currentIndex - 1, -1);
|
const prevIndex = Math.max(currentIndex - 1, -1);
|
||||||
selectSearchResult(items, prevIndex);
|
selectSearchResult(items, prevIndex);
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentIndex >= 0 && items[currentIndex]) {
|
if (currentIndex >= 0 && items[currentIndex]) {
|
||||||
const link = items[currentIndex].querySelector('a');
|
const link = items[currentIndex].querySelector("a");
|
||||||
if (link) link.click();
|
if (link) link.click();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === "Escape") {
|
||||||
hideSearchResults();
|
hideSearchResults();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,22 +82,22 @@ function handleSearchKeydown(e: KeyboardEvent): void {
|
|||||||
function selectSearchResult(items: NodeListOf<Element>, index: number): void {
|
function selectSearchResult(items: NodeListOf<Element>, index: number): void {
|
||||||
items.forEach((item, i) => {
|
items.forEach((item, i) => {
|
||||||
if (i === index) {
|
if (i === index) {
|
||||||
item.classList.add('bg-opacity-80');
|
item.classList.add("bg-opacity-80");
|
||||||
} else {
|
} else {
|
||||||
item.classList.remove('bg-opacity-80');
|
item.classList.remove("bg-opacity-80");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById("search-results");
|
||||||
if (searchResults) {
|
if (searchResults) {
|
||||||
searchResults.dataset.selectedIndex = index.toString();
|
searchResults.dataset.selectedIndex = index.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function performSearch(query: string): void {
|
function performSearch(query: string): void {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem("token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.warn('No authentication token found');
|
console.warn("No authentication token found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,20 +105,25 @@ function performSearch(query: string): void {
|
|||||||
|
|
||||||
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
|
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return { error: 'no results found', results: [] };
|
return { error: "no results found", results: [] };
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
|
.then(
|
||||||
|
(
|
||||||
|
data:
|
||||||
|
| { error?: string; results?: MediaItemSummary[] }
|
||||||
|
| MediaItemSummary[],
|
||||||
|
) => {
|
||||||
hideSearchLoading();
|
hideSearchLoading();
|
||||||
|
|
||||||
if (data && 'error' in data && data.error === 'no results found') {
|
if (data && "error" in data && data.error === "no results found") {
|
||||||
showNoResults(query);
|
showNoResults(query);
|
||||||
} else if (Array.isArray(data) && data.length > 0) {
|
} else if (Array.isArray(data) && data.length > 0) {
|
||||||
showSearchResults(data, query);
|
showSearchResults(data, query);
|
||||||
@@ -121,17 +132,18 @@ function performSearch(query: string): void {
|
|||||||
} else {
|
} else {
|
||||||
showNoResults(query);
|
showNoResults(query);
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
.catch(error => {
|
)
|
||||||
|
.catch((error) => {
|
||||||
hideSearchLoading();
|
hideSearchLoading();
|
||||||
console.error('Search error:', error);
|
console.error("Search error:", error);
|
||||||
showSearchError();
|
showSearchError();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSearchLoading(): void {
|
function showSearchLoading(): void {
|
||||||
createSearchResultsContainer();
|
createSearchResultsContainer();
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById("search-results");
|
||||||
if (!searchResults) return;
|
if (!searchResults) return;
|
||||||
|
|
||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
@@ -140,38 +152,37 @@ function showSearchLoading(): void {
|
|||||||
<p class="mt-2 text-sm">Searching...</p>
|
<p class="mt-2 text-sm">Searching...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideSearchLoading(): void {
|
function hideSearchLoading(): void {}
|
||||||
}
|
|
||||||
|
|
||||||
function showSearchResults(results: MediaItemSummary[], query: string): void {
|
function showSearchResults(results: MediaItemSummary[], query: string): void {
|
||||||
createSearchResultsContainer();
|
createSearchResultsContainer();
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById("search-results");
|
||||||
if (!searchResults) return;
|
if (!searchResults) return;
|
||||||
|
|
||||||
searchResults.dataset.selectedIndex = '-1';
|
searchResults.dataset.selectedIndex = "-1";
|
||||||
|
|
||||||
const libraryIconMap: Record<string, string> = {
|
const libraryIconMap: Record<string, string> = {
|
||||||
'ebooks': '📚',
|
ebooks: "📚",
|
||||||
'comics': '📖',
|
comics: "📖",
|
||||||
'manga': '🗾'
|
manga: "🗾",
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
<div class="p-3 border-b" style="border-color: var(--border)">
|
<div class="p-3 border-b" style="border-color: var(--border)">
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
|
||||||
${results.length} result${results.length !== 1 ? 's' : ''} for "${searchEscapeHtml(query)}"
|
${results.length} result${results.length !== 1 ? "s" : ""} for "${searchEscapeHtml(query)}"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
`;
|
`;
|
||||||
|
|
||||||
results.forEach((item, index) => {
|
results.forEach((item, index) => {
|
||||||
const icon = libraryIconMap[item.library_type_name] || '📁';
|
const icon = libraryIconMap[item.library_type_name] || "📁";
|
||||||
const titleHtml = highlightMatch(item.title, query);
|
const titleHtml = highlightMatch(item.title, query);
|
||||||
const authorHtml = item.author ? highlightMatch(item.author, query) : '';
|
const authorHtml = item.author ? highlightMatch(item.author, query) : "";
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
|
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
|
||||||
@@ -186,7 +197,7 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
|
|||||||
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
|
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
|
||||||
${titleHtml}
|
${titleHtml}
|
||||||
</h4>
|
</h4>
|
||||||
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ''}
|
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ""}
|
||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
||||||
${searchEscapeHtml(item.library_name)}
|
${searchEscapeHtml(item.library_name)}
|
||||||
</p>
|
</p>
|
||||||
@@ -208,12 +219,12 @@ function showSearchResults(results: MediaItemSummary[], query: string): void {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
searchResults.innerHTML = html;
|
searchResults.innerHTML = html;
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNoResults(query: string): void {
|
function showNoResults(query: string): void {
|
||||||
createSearchResultsContainer();
|
createSearchResultsContainer();
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById("search-results");
|
||||||
if (!searchResults) return;
|
if (!searchResults) return;
|
||||||
|
|
||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
@@ -223,12 +234,12 @@ function showNoResults(query: string): void {
|
|||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSearchError(): void {
|
function showSearchError(): void {
|
||||||
createSearchResultsContainer();
|
createSearchResultsContainer();
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById("search-results");
|
||||||
if (!searchResults) return;
|
if (!searchResults) return;
|
||||||
|
|
||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
@@ -238,27 +249,29 @@ function showSearchError(): void {
|
|||||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p>
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideSearchResults(): void {
|
function hideSearchResults(): void {
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById("search-results");
|
||||||
if (searchResults) {
|
if (searchResults) {
|
||||||
searchResults.classList.add('hidden');
|
searchResults.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSearchResultsContainer(): void {
|
function createSearchResultsContainer(): void {
|
||||||
let searchResults = document.getElementById('search-results');
|
let searchResults = document.getElementById("search-results");
|
||||||
if (!searchResults) {
|
if (!searchResults) {
|
||||||
searchResults = document.createElement('div');
|
searchResults = document.createElement("div");
|
||||||
searchResults.id = 'search-results';
|
searchResults.id = "search-results";
|
||||||
searchResults.className = 'hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border';
|
searchResults.className =
|
||||||
searchResults.style.cssText = 'background-color: var(--bg-secondary); border-color: var(--border)';
|
"hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border";
|
||||||
|
searchResults.style.cssText =
|
||||||
|
"background-color: var(--bg-secondary); border-color: var(--border)";
|
||||||
|
|
||||||
const searchInput = document.getElementById('header-search');
|
const searchInput = document.getElementById("header-search");
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
const searchContainer = searchInput.closest('.relative');
|
const searchContainer = searchInput.closest(".relative");
|
||||||
if (searchContainer) {
|
if (searchContainer) {
|
||||||
searchContainer.appendChild(searchResults);
|
searchContainer.appendChild(searchResults);
|
||||||
}
|
}
|
||||||
@@ -267,24 +280,27 @@ function createSearchResultsContainer(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function highlightMatch(text: string, query: string): string {
|
function highlightMatch(text: string, query: string): string {
|
||||||
if (!text) return '';
|
if (!text) return "";
|
||||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||||
return searchEscapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>');
|
return searchEscapeHtml(text).replace(
|
||||||
|
regex,
|
||||||
|
'<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchEscapeHtml(text: string): string {
|
function searchEscapeHtml(text: string): string {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement("div");
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectLibraryAndBook(libraryId: string, bookId: string): void {
|
function selectLibraryAndBook(libraryId: string, bookId: string): void {
|
||||||
localStorage.setItem('selectedLibrary', libraryId);
|
localStorage.setItem("selectedLibrary", libraryId);
|
||||||
localStorage.setItem('selectedBook', bookId);
|
localStorage.setItem("selectedBook", bookId);
|
||||||
hideSearchResults();
|
hideSearchResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initializeSearch);
|
document.addEventListener("DOMContentLoaded", initializeSearch);
|
||||||
|
|
||||||
(window as any).selectLibraryAndBook = selectLibraryAndBook;
|
(window as any).selectLibraryAndBook = selectLibraryAndBook;
|
||||||
|
|||||||
+14
-14
@@ -1,49 +1,49 @@
|
|||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
return localStorage.getItem('token');
|
return localStorage.getItem("token");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToken(token: string): void {
|
function setToken(token: string): void {
|
||||||
localStorage.setItem('token', token);
|
localStorage.setItem("token", token);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeToken(): void {
|
function removeToken(): void {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem("token");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRefreshToken(): string | null {
|
function getRefreshToken(): string | null {
|
||||||
return localStorage.getItem('refresh_token');
|
return localStorage.getItem("refresh_token");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRefreshToken(token: string): void {
|
function setRefreshToken(token: string): void {
|
||||||
localStorage.setItem('refresh_token', token);
|
localStorage.setItem("refresh_token", token);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRefreshToken(): void {
|
function removeRefreshToken(): void {
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem("refresh_token");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTheme(): string {
|
function getTheme(): string {
|
||||||
return localStorage.getItem('theme') || 'tokyo-night';
|
return localStorage.getItem("theme") || "tokyo-night";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(theme: string): void {
|
function setTheme(theme: string): void {
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem("theme", theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedLibrary(): string | null {
|
function getSelectedLibrary(): string | null {
|
||||||
return localStorage.getItem('selectedLibrary');
|
return localStorage.getItem("selectedLibrary");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelectedLibrary(libraryId: string): void {
|
function setSelectedLibrary(libraryId: string): void {
|
||||||
localStorage.setItem('selectedLibrary', libraryId);
|
localStorage.setItem("selectedLibrary", libraryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedBook(): string | null {
|
function getSelectedBook(): string | null {
|
||||||
return localStorage.getItem('selectedBook');
|
return localStorage.getItem("selectedBook");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelectedBook(bookId: string): void {
|
function setSelectedBook(bookId: string): void {
|
||||||
localStorage.setItem('selectedBook', bookId);
|
localStorage.setItem("selectedBook", bookId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAll(): void {
|
function clearAll(): void {
|
||||||
@@ -63,7 +63,7 @@ function clearAll(): void {
|
|||||||
setSelectedLibrary,
|
setSelectedLibrary,
|
||||||
getSelectedBook,
|
getSelectedBook,
|
||||||
setSelectedBook,
|
setSelectedBook,
|
||||||
clearAll
|
clearAll,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -79,5 +79,5 @@ export {
|
|||||||
setSelectedLibrary,
|
setSelectedLibrary,
|
||||||
getSelectedBook,
|
getSelectedBook,
|
||||||
setSelectedBook,
|
setSelectedBook,
|
||||||
clearAll
|
clearAll,
|
||||||
};
|
};
|
||||||
|
|||||||
+45
-38
@@ -1,43 +1,47 @@
|
|||||||
// Theme management functionality
|
// Theme management functionality
|
||||||
|
|
||||||
type ThemeType =
|
type ThemeType =
|
||||||
| 'tokyo-night'
|
| "tokyo-night"
|
||||||
| 'dracula'
|
| "dracula"
|
||||||
| 'nord'
|
| "nord"
|
||||||
| 'solarized-dark'
|
| "solarized-dark"
|
||||||
| 'monokai'
|
| "monokai"
|
||||||
| 'one-dark-pro'
|
| "one-dark-pro"
|
||||||
| 'material-dark'
|
| "material-dark"
|
||||||
| 'catppuccin-mocha'
|
| "catppuccin-mocha"
|
||||||
| 'catppuccin-macchiato'
|
| "catppuccin-macchiato"
|
||||||
| 'catppuccin-frappe'
|
| "catppuccin-frappe"
|
||||||
| 'catppuccin-latte';
|
| "catppuccin-latte";
|
||||||
|
|
||||||
const DEFAULT_THEME: ThemeType = 'tokyo-night';
|
const DEFAULT_THEME: ThemeType = "tokyo-night";
|
||||||
const THEME_STORAGE_KEY = 'theme';
|
const THEME_STORAGE_KEY = "theme";
|
||||||
const TOKEN_STORAGE_KEY = 'token';
|
const TOKEN_STORAGE_KEY = "token";
|
||||||
|
|
||||||
// Apply theme to document body
|
// Apply theme to document body
|
||||||
const applyTheme = (theme: string): void => {
|
const applyTheme = (theme: string): void => {
|
||||||
// Apply regular theme only
|
// Apply regular theme only
|
||||||
document.body.className = `theme-${theme}`;
|
document.body.className = `theme-${theme}`;
|
||||||
document.body.style.background = '';
|
document.body.style.background = "";
|
||||||
document.body.style.backgroundSize = '';
|
document.body.style.backgroundSize = "";
|
||||||
document.body.style.backgroundAttachment = '';
|
document.body.style.backgroundAttachment = "";
|
||||||
|
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load theme from localStorage or use default
|
// Load theme from localStorage or use default
|
||||||
const loadTheme = (): void => {
|
const loadTheme = (): void => {
|
||||||
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null;
|
const storedTheme = localStorage.getItem(
|
||||||
|
THEME_STORAGE_KEY,
|
||||||
|
) as ThemeType | null;
|
||||||
const theme = storedTheme || DEFAULT_THEME;
|
const theme = storedTheme || DEFAULT_THEME;
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle theme change from user selection
|
// Handle theme change from user selection
|
||||||
const changeTheme = async (): Promise<void> => {
|
const changeTheme = async (): Promise<void> => {
|
||||||
const themeSelect = document.getElementById('theme-select') as HTMLSelectElement;
|
const themeSelect = document.getElementById(
|
||||||
|
"theme-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
if (!themeSelect) return;
|
if (!themeSelect) return;
|
||||||
|
|
||||||
const theme = themeSelect.value as ThemeType;
|
const theme = themeSelect.value as ThemeType;
|
||||||
@@ -48,20 +52,20 @@ const changeTheme = async (): Promise<void> => {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/theme', {
|
const response = await fetch("/api/auth/theme", {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'Authorization': `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ theme })
|
body: JSON.stringify({ theme }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.log('Theme save failed');
|
console.log("Theme save failed");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Theme save failed', error);
|
console.log("Theme save failed", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,8 +75,8 @@ const loadUserTheme = async (): Promise<void> => {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/profile', {
|
const response = await fetch("/api/auth/profile", {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -92,9 +96,12 @@ const initializeTheme = (): void => {
|
|||||||
loadUserTheme();
|
loadUserTheme();
|
||||||
|
|
||||||
// Set theme select value to current theme
|
// Set theme select value to current theme
|
||||||
const themeSelect = document.getElementById('theme-select') as HTMLSelectElement;
|
const themeSelect = document.getElementById(
|
||||||
|
"theme-select",
|
||||||
|
) as HTMLSelectElement;
|
||||||
if (themeSelect) {
|
if (themeSelect) {
|
||||||
const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
|
const currentTheme =
|
||||||
|
localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
|
||||||
themeSelect.value = currentTheme;
|
themeSelect.value = currentTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,17 +111,17 @@ const initializeTheme = (): void => {
|
|||||||
|
|
||||||
// Setup smooth scrolling for anchor links
|
// Setup smooth scrolling for anchor links
|
||||||
const setupSmoothScroll = (): void => {
|
const setupSmoothScroll = (): void => {
|
||||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
|
||||||
anchor.addEventListener('click', (e) => {
|
anchor.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const href = anchor.getAttribute('href');
|
const href = anchor.getAttribute("href");
|
||||||
if (!href) return;
|
if (!href) return;
|
||||||
|
|
||||||
const target = document.querySelector(href);
|
const target = document.querySelector(href);
|
||||||
if (target) {
|
if (target) {
|
||||||
target.scrollIntoView({
|
target.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: "smooth",
|
||||||
block: 'start'
|
block: "start",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -122,9 +129,9 @@ const setupSmoothScroll = (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Auto-initialize when DOM is ready
|
// Auto-initialize when DOM is ready
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== "undefined") {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', initializeTheme);
|
document.addEventListener("DOMContentLoaded", initializeTheme);
|
||||||
} else {
|
} else {
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -2,22 +2,22 @@
|
|||||||
|
|
||||||
// Update visual indicators for theme buttons
|
// Update visual indicators for theme buttons
|
||||||
const updateThemeIndicators = (): void => {
|
const updateThemeIndicators = (): void => {
|
||||||
const currentTheme = localStorage.getItem('theme') || 'tokyo-night';
|
const currentTheme = localStorage.getItem("theme") || "tokyo-night";
|
||||||
|
|
||||||
// Update theme buttons (all buttons with changeThemeTo onclick)
|
// Update theme buttons (all buttons with changeThemeTo onclick)
|
||||||
document.querySelectorAll('[onclick^="changeThemeTo"]').forEach(btn => {
|
document.querySelectorAll('[onclick^="changeThemeTo"]').forEach((btn) => {
|
||||||
const onclick = btn.getAttribute('onclick') || '';
|
const onclick = btn.getAttribute("onclick") || "";
|
||||||
const match = onclick.match(/changeThemeTo\('(.+?)'\)/);
|
const match = onclick.match(/changeThemeTo\('(.+?)'\)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const theme = match[1];
|
const theme = match[1];
|
||||||
if (theme === currentTheme) {
|
if (theme === currentTheme) {
|
||||||
// Active state - use CSS class instead of inline style
|
// Active state - use CSS class instead of inline style
|
||||||
btn.classList.add('bg-theme-active');
|
btn.classList.add("bg-theme-active");
|
||||||
btn.classList.remove('bg-theme-inactive');
|
btn.classList.remove("bg-theme-inactive");
|
||||||
} else {
|
} else {
|
||||||
// Inactive state
|
// Inactive state
|
||||||
btn.classList.remove('bg-theme-active');
|
btn.classList.remove("bg-theme-active");
|
||||||
btn.classList.add('bg-theme-inactive');
|
btn.classList.add("bg-theme-inactive");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -46,9 +46,9 @@ if (originalChangeThemeTo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-initialize when DOM is ready
|
// Auto-initialize when DOM is ready
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== "undefined") {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', updateThemeIndicators);
|
document.addEventListener("DOMContentLoaded", updateThemeIndicators);
|
||||||
} else {
|
} else {
|
||||||
updateThemeIndicators();
|
updateThemeIndicators();
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-43
@@ -1,17 +1,18 @@
|
|||||||
// Toast notification system for backend errors
|
// Toast notification system for backend errors
|
||||||
// Displays toast notifications at the top of the page
|
// Displays toast notifications at the top of the page
|
||||||
|
|
||||||
type ToastType = 'error' | 'success' | 'info';
|
type ToastType = "error" | "success" | "info";
|
||||||
|
|
||||||
const TOAST_DEFAULT_DURATION = 5000;
|
const TOAST_DEFAULT_DURATION = 5000;
|
||||||
|
|
||||||
// Create toast container
|
// Create toast container
|
||||||
const createToastContainer = (): HTMLElement => {
|
const createToastContainer = (): HTMLElement => {
|
||||||
let container = document.getElementById('toast-container');
|
let container = document.getElementById("toast-container");
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement('div');
|
container = document.createElement("div");
|
||||||
container.id = 'toast-container';
|
container.id = "toast-container";
|
||||||
container.className = 'fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none';
|
container.className =
|
||||||
|
"fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
}
|
}
|
||||||
return container;
|
return container;
|
||||||
@@ -19,7 +20,7 @@ const createToastContainer = (): HTMLElement => {
|
|||||||
|
|
||||||
// Escape HTML to prevent XSS
|
// Escape HTML to prevent XSS
|
||||||
const toastEscapeHtml = (text: string): string => {
|
const toastEscapeHtml = (text: string): string => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement("div");
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
@@ -28,24 +29,24 @@ const toastEscapeHtml = (text: string): string => {
|
|||||||
const getToastConfig = (type: ToastType) => {
|
const getToastConfig = (type: ToastType) => {
|
||||||
const configs = {
|
const configs = {
|
||||||
error: {
|
error: {
|
||||||
bgClass: 'bg-red-500/90',
|
bgClass: "bg-red-500/90",
|
||||||
icon: '❌'
|
icon: "❌",
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
bgClass: 'bg-green-500/90',
|
bgClass: "bg-green-500/90",
|
||||||
icon: '✅'
|
icon: "✅",
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
bgClass: 'bg-blue-500/90',
|
bgClass: "bg-blue-500/90",
|
||||||
icon: 'ℹ️'
|
icon: "ℹ️",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
return configs[type];
|
return configs[type];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a toast element
|
// Create a toast element
|
||||||
const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement("div");
|
||||||
const config = getToastConfig(type);
|
const config = getToastConfig(type);
|
||||||
|
|
||||||
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
|
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
|
||||||
@@ -59,7 +60,7 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Add close button handler
|
// Add close button handler
|
||||||
const closeBtn = toast.querySelector('.toast-close') as HTMLElement;
|
const closeBtn = toast.querySelector(".toast-close") as HTMLElement;
|
||||||
if (closeBtn) {
|
if (closeBtn) {
|
||||||
closeBtn.onclick = () => removeToast(toast);
|
closeBtn.onclick = () => removeToast(toast);
|
||||||
}
|
}
|
||||||
@@ -70,15 +71,15 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
|||||||
// Trigger toast animation
|
// Trigger toast animation
|
||||||
const animateToastIn = (toast: HTMLElement): void => {
|
const animateToastIn = (toast: HTMLElement): void => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.style.opacity = '1';
|
toast.style.opacity = "1";
|
||||||
toast.style.transform = 'translateY(0)';
|
toast.style.transform = "translateY(0)";
|
||||||
}, 10);
|
}, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove toast with animation
|
// Remove toast with animation
|
||||||
const removeToast = (toast: HTMLElement): void => {
|
const removeToast = (toast: HTMLElement): void => {
|
||||||
toast.style.opacity = '0';
|
toast.style.opacity = "0";
|
||||||
toast.style.transform = 'translateY(-20px)';
|
toast.style.transform = "translateY(-20px)";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (toast.parentElement) {
|
if (toast.parentElement) {
|
||||||
toast.parentElement.removeChild(toast);
|
toast.parentElement.removeChild(toast);
|
||||||
@@ -87,7 +88,11 @@ const removeToast = (toast: HTMLElement): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Show toast notification
|
// Show toast notification
|
||||||
const showToast = (message: string, type: ToastType, duration: number = TOAST_DEFAULT_DURATION): void => {
|
const showToast = (
|
||||||
|
message: string,
|
||||||
|
type: ToastType,
|
||||||
|
duration: number = TOAST_DEFAULT_DURATION,
|
||||||
|
): void => {
|
||||||
const container = createToastContainer();
|
const container = createToastContainer();
|
||||||
const toast = createToastElement(message, type);
|
const toast = createToastElement(message, type);
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
@@ -101,7 +106,7 @@ const showToast = (message: string, type: ToastType, duration: number = TOAST_DE
|
|||||||
|
|
||||||
// Parse error from XHR response
|
// Parse error from XHR response
|
||||||
const parseXHRError = (xhr: XMLHttpRequest): string => {
|
const parseXHRError = (xhr: XMLHttpRequest): string => {
|
||||||
let errorMessage = 'An error occurred';
|
let errorMessage = "An error occurred";
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(xhr.responseText);
|
const response = JSON.parse(xhr.responseText);
|
||||||
errorMessage = response.error || response.message || errorMessage;
|
errorMessage = response.error || response.message || errorMessage;
|
||||||
@@ -113,8 +118,8 @@ const parseXHRError = (xhr: XMLHttpRequest): string => {
|
|||||||
|
|
||||||
// Parse error from fetch response
|
// Parse error from fetch response
|
||||||
const parseFetchError = async (response: Response): Promise<string> => {
|
const parseFetchError = async (response: Response): Promise<string> => {
|
||||||
const contentType = response.headers.get('content-type');
|
const contentType = response.headers.get("content-type");
|
||||||
if (contentType && contentType.includes('application/json')) {
|
if (contentType && contentType.includes("application/json")) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.error || data.message || `Error ${response.status}`;
|
return data.error || data.message || `Error ${response.status}`;
|
||||||
}
|
}
|
||||||
@@ -124,7 +129,7 @@ const parseFetchError = async (response: Response): Promise<string> => {
|
|||||||
// Setup HTMX error listeners
|
// Setup HTMX error listeners
|
||||||
const setupHTMXListeners = (): void => {
|
const setupHTMXListeners = (): void => {
|
||||||
// Listen for HTMX afterSwap event to detect errors in swapped content
|
// Listen for HTMX afterSwap event to detect errors in swapped content
|
||||||
document.body.addEventListener('htmx:afterSwap', (evt: Event) => {
|
document.body.addEventListener("htmx:afterSwap", (evt: Event) => {
|
||||||
interface HTMXEventDetail {
|
interface HTMXEventDetail {
|
||||||
xhr: XMLHttpRequest;
|
xhr: XMLHttpRequest;
|
||||||
succeeded: boolean;
|
succeeded: boolean;
|
||||||
@@ -140,47 +145,49 @@ const setupHTMXListeners = (): void => {
|
|||||||
// Show toast for HTTP errors
|
// Show toast for HTTP errors
|
||||||
if (xhr.status >= 400 && xhr.status < 600) {
|
if (xhr.status >= 400 && xhr.status < 600) {
|
||||||
const errorMessage = parseXHRError(xhr);
|
const errorMessage = parseXHRError(xhr);
|
||||||
showToast(errorMessage, 'error');
|
showToast(errorMessage, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also listen for response errors (network issues, invalid responses)
|
// Also listen for response errors (network issues, invalid responses)
|
||||||
document.body.addEventListener('htmx:responseError', (evt: Event) => {
|
document.body.addEventListener("htmx:responseError", (evt: Event) => {
|
||||||
const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>;
|
const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>;
|
||||||
const xhr = customEvent.detail.xhr;
|
const xhr = customEvent.detail.xhr;
|
||||||
const errorMessage = parseXHRError(xhr);
|
const errorMessage = parseXHRError(xhr);
|
||||||
showToast(errorMessage, 'error');
|
showToast(errorMessage, "error");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup fetch interceptor
|
// Setup fetch interceptor
|
||||||
const setupFetchInterceptor = (): void => {
|
const setupFetchInterceptor = (): void => {
|
||||||
const originalFetch = window.fetch;
|
const originalFetch = window.fetch;
|
||||||
window.fetch = async (...args: Parameters<typeof fetch>): Promise<Response> => {
|
window.fetch = async (
|
||||||
|
...args: Parameters<typeof fetch>
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const response = await originalFetch(...args);
|
const response = await originalFetch(...args);
|
||||||
|
|
||||||
// Special handling for 401 Unauthorized
|
// Special handling for 401 Unauthorized
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// Clear invalid tokens from localStorage
|
// Clear invalid tokens from localStorage
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem("refreshToken");
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem("user");
|
||||||
|
|
||||||
// Check if this was a page navigation (not API call)
|
// Check if this was a page navigation (not API call)
|
||||||
const url = args[0] as string;
|
const url = args[0] as string;
|
||||||
|
|
||||||
// Don't show toast for page navigations - will be handled by redirect
|
// Don't show toast for page navigations - will be handled by redirect
|
||||||
if (!url.startsWith('/api/')) {
|
if (!url.startsWith("/api/")) {
|
||||||
// Direct navigation to protected page will be caught by middleware
|
// Direct navigation to protected page will be caught by middleware
|
||||||
// Just throw to prevent further processing
|
// Just throw to prevent further processing
|
||||||
throw new Error('Session expired');
|
throw new Error("Session expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
// API call - show toast error
|
// API call - show toast error
|
||||||
const errorMessage = await parseFetchError(response);
|
const errorMessage = await parseFetchError(response);
|
||||||
showToast(errorMessage, 'error');
|
showToast(errorMessage, "error");
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -188,14 +195,14 @@ const setupFetchInterceptor = (): void => {
|
|||||||
// Handle other errors
|
// Handle other errors
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorMessage = await parseFetchError(response);
|
const errorMessage = await parseFetchError(response);
|
||||||
showToast(errorMessage, 'error');
|
showToast(errorMessage, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't show toast for redirect errors
|
// Don't show toast for redirect errors
|
||||||
if ((error as Error).message !== 'Session expired') {
|
if ((error as Error).message !== "Session expired") {
|
||||||
showToast('Network error: Unable to connect to server', 'error');
|
showToast("Network error: Unable to connect to server", "error");
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -209,9 +216,9 @@ const initializeToastSystem = (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Auto-initialize when DOM is ready
|
// Auto-initialize when DOM is ready
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== "undefined") {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', initializeToastSystem);
|
document.addEventListener("DOMContentLoaded", initializeToastSystem);
|
||||||
} else {
|
} else {
|
||||||
initializeToastSystem();
|
initializeToastSystem();
|
||||||
}
|
}
|
||||||
@@ -219,7 +226,10 @@ if (typeof document !== 'undefined') {
|
|||||||
|
|
||||||
// Export toast API for manual use
|
// Export toast API for manual use
|
||||||
(window as any).showToast = {
|
(window as any).showToast = {
|
||||||
error: (message: string, duration?: number) => showToast(message, 'error', duration),
|
error: (message: string, duration?: number) =>
|
||||||
success: (message: string, duration?: number) => showToast(message, 'success', duration),
|
showToast(message, "error", duration),
|
||||||
info: (message: string, duration?: number) => showToast(message, 'info', duration)
|
success: (message: string, duration?: number) =>
|
||||||
|
showToast(message, "success", duration),
|
||||||
|
info: (message: string, duration?: number) =>
|
||||||
|
showToast(message, "info", duration),
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+18
-3
@@ -113,7 +113,7 @@ interface UnlinkedBookData {
|
|||||||
progress_id: string;
|
progress_id: string;
|
||||||
device_id: string;
|
device_id: string;
|
||||||
device_name: string;
|
device_name: string;
|
||||||
device_type: 'koreader' | 'kobo' | 'web';
|
device_type: "koreader" | "kobo" | "web";
|
||||||
title_from_device: string;
|
title_from_device: string;
|
||||||
file_path: string;
|
file_path: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
@@ -134,8 +134,23 @@ interface PotentialMatchData {
|
|||||||
// Used in: collection_rules.ts
|
// Used in: collection_rules.ts
|
||||||
interface CollectionRule {
|
interface CollectionRule {
|
||||||
id: string;
|
id: string;
|
||||||
field: 'genre' | 'series' | 'author' | 'language' | 'publisher' | 'copyright_year' | 'tags';
|
field:
|
||||||
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'greater_than' | 'less_than';
|
| "genre"
|
||||||
|
| "series"
|
||||||
|
| "author"
|
||||||
|
| "language"
|
||||||
|
| "publisher"
|
||||||
|
| "copyright_year"
|
||||||
|
| "tags";
|
||||||
|
operator:
|
||||||
|
| "equals"
|
||||||
|
| "not_equals"
|
||||||
|
| "contains"
|
||||||
|
| "not_contains"
|
||||||
|
| "starts_with"
|
||||||
|
| "ends_with"
|
||||||
|
| "greater_than"
|
||||||
|
| "less_than";
|
||||||
value: string;
|
value: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
|||||||
+27
-21
@@ -1,22 +1,26 @@
|
|||||||
// Wood paneling management functionality
|
// Wood paneling management functionality
|
||||||
|
|
||||||
type WoodPanelingType = 'none' | 'wood-light' | 'wood-dark' | 'wood-mahogany';
|
type WoodPanelingType = "none" | "wood-light" | "wood-dark" | "wood-mahogany";
|
||||||
|
|
||||||
const WOOD_STORAGE_KEY = 'wood-paneling';
|
const WOOD_STORAGE_KEY = "wood-paneling";
|
||||||
|
|
||||||
// Apply wood paneling to collections container
|
// Apply wood paneling to collections container
|
||||||
const applyWoodPaneling = (paneling: WoodPanelingType): void => {
|
const applyWoodPaneling = (paneling: WoodPanelingType): void => {
|
||||||
const container = document.getElementById('collections-container');
|
const container = document.getElementById("collections-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Remove all wood background classes
|
// Remove all wood background classes
|
||||||
container.classList.remove('bg-wood-light', 'bg-wood-dark', 'bg-wood-mahogany');
|
container.classList.remove(
|
||||||
container.removeAttribute('data-wood');
|
"bg-wood-light",
|
||||||
|
"bg-wood-dark",
|
||||||
|
"bg-wood-mahogany",
|
||||||
|
);
|
||||||
|
container.removeAttribute("data-wood");
|
||||||
|
|
||||||
if (paneling !== 'none') {
|
if (paneling !== "none") {
|
||||||
// Add selected wood background class
|
// Add selected wood background class
|
||||||
container.classList.add(`bg-${paneling}`);
|
container.classList.add(`bg-${paneling}`);
|
||||||
container.setAttribute('data-wood', paneling);
|
container.setAttribute("data-wood", paneling);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
@@ -25,12 +29,14 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => {
|
|||||||
|
|
||||||
// Load wood paneling from localStorage on page load
|
// Load wood paneling from localStorage on page load
|
||||||
const loadWoodPaneling = (): void => {
|
const loadWoodPaneling = (): void => {
|
||||||
const stored = localStorage.getItem(WOOD_STORAGE_KEY) as WoodPanelingType | null;
|
const stored = localStorage.getItem(
|
||||||
|
WOOD_STORAGE_KEY,
|
||||||
|
) as WoodPanelingType | null;
|
||||||
if (stored) {
|
if (stored) {
|
||||||
applyWoodPaneling(stored);
|
applyWoodPaneling(stored);
|
||||||
} else {
|
} else {
|
||||||
// Default to none
|
// Default to none
|
||||||
applyWoodPaneling('none');
|
applyWoodPaneling("none");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,27 +48,27 @@ const changeWoodPaneling = (paneling: WoodPanelingType): void => {
|
|||||||
updateWoodPanelingIndicators();
|
updateWoodPanelingIndicators();
|
||||||
|
|
||||||
// Close dropdown
|
// Close dropdown
|
||||||
const dropdown = document.getElementById('theme-dropdown');
|
const dropdown = document.getElementById("theme-dropdown");
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.classList.add('hidden');
|
dropdown.classList.add("hidden");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update visual indicators for wood paneling buttons
|
// Update visual indicators for wood paneling buttons
|
||||||
const updateWoodPanelingIndicators = (): void => {
|
const updateWoodPanelingIndicators = (): void => {
|
||||||
const currentWood = localStorage.getItem(WOOD_STORAGE_KEY) || 'none';
|
const currentWood = localStorage.getItem(WOOD_STORAGE_KEY) || "none";
|
||||||
|
|
||||||
// Update wood paneling buttons
|
// Update wood paneling buttons
|
||||||
document.querySelectorAll('.wood-paneling-btn').forEach(btn => {
|
document.querySelectorAll(".wood-paneling-btn").forEach((btn) => {
|
||||||
const wood = btn.getAttribute('data-wood');
|
const wood = btn.getAttribute("data-wood");
|
||||||
if (wood === currentWood) {
|
if (wood === currentWood) {
|
||||||
// Active state - use CSS class instead of inline style
|
// Active state - use CSS class instead of inline style
|
||||||
btn.classList.add('bg-wood-active');
|
btn.classList.add("bg-wood-active");
|
||||||
btn.classList.remove('bg-wood-inactive');
|
btn.classList.remove("bg-wood-inactive");
|
||||||
} else {
|
} else {
|
||||||
// Inactive state
|
// Inactive state
|
||||||
btn.classList.remove('bg-wood-active');
|
btn.classList.remove("bg-wood-active");
|
||||||
btn.classList.add('bg-wood-inactive');
|
btn.classList.add("bg-wood-inactive");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -73,9 +79,9 @@ const updateWoodPanelingIndicators = (): void => {
|
|||||||
(window as any).updateWoodPanelingIndicators = updateWoodPanelingIndicators;
|
(window as any).updateWoodPanelingIndicators = updateWoodPanelingIndicators;
|
||||||
|
|
||||||
// Auto-initialize when DOM is ready
|
// Auto-initialize when DOM is ready
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== "undefined") {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
loadWoodPaneling();
|
loadWoodPaneling();
|
||||||
updateWoodPanelingIndicators();
|
updateWoodPanelingIndicators();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
// Early initialization script to prevent flash of wrong background
|
// Early initialization script to prevent flash of wrong background
|
||||||
// Loads before woodPaneling.js to apply paneling immediately
|
// Loads before woodPaneling.js to apply paneling immediately
|
||||||
|
|
||||||
const WOOD_INIT_STORAGE_KEY = 'wood-paneling';
|
const WOOD_INIT_STORAGE_KEY = "wood-paneling";
|
||||||
|
|
||||||
// Apply wood paneling immediately (before DOM ready if possible)
|
// Apply wood paneling immediately (before DOM ready if possible)
|
||||||
(function() {
|
(function () {
|
||||||
const woodPaneling = localStorage.getItem(WOOD_INIT_STORAGE_KEY) || 'none';
|
const woodPaneling = localStorage.getItem(WOOD_INIT_STORAGE_KEY) || "none";
|
||||||
if (woodPaneling !== 'none') {
|
if (woodPaneling !== "none") {
|
||||||
const applyPaneling = () => {
|
const applyPaneling = () => {
|
||||||
const container = document.getElementById('collections-container');
|
const container = document.getElementById("collections-container");
|
||||||
if (container) {
|
if (container) {
|
||||||
container.classList.add(`bg-${woodPaneling}`);
|
container.classList.add(`bg-${woodPaneling}`);
|
||||||
container.setAttribute('data-wood', woodPaneling);
|
container.setAttribute("data-wood", woodPaneling);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply immediately if DOM is ready, otherwise wait
|
// Apply immediately if DOM is ready, otherwise wait
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', applyPaneling);
|
document.addEventListener("DOMContentLoaded", applyPaneling);
|
||||||
} else {
|
} else {
|
||||||
applyPaneling();
|
applyPaneling();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user