Create a new /series/detail?name=X&library_id=Y SSR page that shows all books in a specific series, replacing the broken approach of linking to /bookshelf?series_filter=X (the bookshelf SSR handler ignores all filter query params). The series detail page features: - Back link to /series browse page - Library selector dropdown (full page navigation on change) - Series name header with book count badge - Book grid using the shared BookCard template - Empty state for series with no books Update all links to point to the new page: - Series cards on /series browse page - Series badge on book detail page - JS-rendered cards in series.ts switchLibrary Add seriesDetailPage Alpine component for the detail page's library switcher (simple navigation, no AJAX needed).
174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
import { setSelectedLibrary } from "./storage";
|
|
|
|
interface SeriesItem {
|
|
name: string;
|
|
book_count: number;
|
|
total_in_series: number;
|
|
cover_paths: string[];
|
|
last_entry_at: string;
|
|
}
|
|
|
|
function renderSeriesCard(series: SeriesItem, libraryId: string): string {
|
|
const href = `/series/detail?name=${encodeURIComponent(series.name)}&library_id=${libraryId}`;
|
|
const coverCount = series.cover_paths.length;
|
|
const coverClass = `cover-count-${coverCount}`;
|
|
|
|
let coversHtml = "";
|
|
for (let i = 0; i < coverCount; i++) {
|
|
coversHtml += `<img src="${series.cover_paths[i]}" class="cover-img cover-${i}" alt="${series.name}" loading="lazy" onerror="this.src='/static/placeholder-book.svg'">`;
|
|
}
|
|
|
|
if (coverCount === 0) {
|
|
coversHtml = `<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary);"><span class="text-3xl">📚</span></div>`;
|
|
}
|
|
|
|
return `
|
|
<a href="${href}" class="block">
|
|
<div class="series-card rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow" style="background-color: var(--bg-secondary);">
|
|
<div class="stacked-covers ${coverClass}">
|
|
${coversHtml}
|
|
</div>
|
|
<div class="series-card-info px-3 py-2">
|
|
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">${series.name}</h3>
|
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">
|
|
${series.book_count} of ${series.total_in_series} books
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
function renderSeriesContent(
|
|
seriesList: SeriesItem[],
|
|
libraryId: string,
|
|
totalPages: number,
|
|
currentPage: number,
|
|
): string {
|
|
if (seriesList.length === 0) {
|
|
return `
|
|
<main class="w-full px-4 py-8">
|
|
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
|
|
<div class="text-center py-16" style="color: var(--text-secondary)">
|
|
<div class="text-6xl mb-4">📚</div>
|
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Found</h3>
|
|
<p>Books with series metadata will appear here</p>
|
|
</div>
|
|
</main>`;
|
|
}
|
|
|
|
const cardsHtml = seriesList.map((s) => renderSeriesCard(s, libraryId)).join("");
|
|
|
|
let paginationHtml = "";
|
|
if (totalPages > 1) {
|
|
const prevLink =
|
|
currentPage > 1
|
|
? `<a href="/series?library_id=${libraryId}&page=${currentPage - 1}" class="px-4 py-2 rounded-lg border" style="border-color: var(--border); color: var(--text-primary);">← Previous</a>`
|
|
: "";
|
|
const nextLink =
|
|
currentPage < totalPages
|
|
? `<a href="/series?library_id=${libraryId}&page=${currentPage + 1}" class="px-4 py-2 rounded-lg border" style="border-color: var(--border); color: var(--text-primary);">Next →</a>`
|
|
: "";
|
|
paginationHtml = `
|
|
<div class="flex justify-center items-center gap-4 mt-8">
|
|
${prevLink}
|
|
<span class="text-sm" style="color: var(--text-secondary)">Page ${currentPage} of ${totalPages}</span>
|
|
${nextLink}
|
|
</div>`;
|
|
}
|
|
|
|
return `
|
|
<main class="w-full px-4 py-8">
|
|
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
|
${cardsHtml}
|
|
</div>
|
|
${paginationHtml}
|
|
</main>`;
|
|
}
|
|
|
|
async function switchLibrary(libraryId: string): Promise<void> {
|
|
const container = document.getElementById("series-container") as HTMLElement;
|
|
const loading = document.getElementById("loading-spinner") as HTMLElement;
|
|
|
|
if (!container || !loading) return;
|
|
|
|
try {
|
|
container.classList.add("opacity-0", "transition-opacity", "duration-150");
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
loading.classList.remove("hidden");
|
|
|
|
const response = await fetch(
|
|
`/api/series?library_id=${libraryId}&limit=24&offset=0`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to load series");
|
|
}
|
|
|
|
const data = await response.json();
|
|
const seriesList: SeriesItem[] = data.series || [];
|
|
const total: number = data.total || 0;
|
|
const totalPages = Math.max(1, Math.ceil(total / 24));
|
|
|
|
container.innerHTML = renderSeriesContent(seriesList, libraryId, totalPages, 1);
|
|
setSelectedLibrary(libraryId);
|
|
} catch (error) {
|
|
showToast("Failed to load series", "error");
|
|
console.error("Switch library error:", error);
|
|
} finally {
|
|
loading.classList.add("hidden");
|
|
|
|
container.classList.remove("duration-150");
|
|
container.classList.add("duration-300");
|
|
void container.offsetHeight;
|
|
container.classList.remove("opacity-0");
|
|
|
|
setTimeout(() => {
|
|
container.classList.remove("transition-opacity", "duration-300");
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
Alpine.data("seriesPage", () => ({
|
|
initSeriesPage() {
|
|
const librarySelect = document.getElementById(
|
|
"library-select",
|
|
) as HTMLSelectElement;
|
|
if (librarySelect) {
|
|
librarySelect.addEventListener("change", () => {
|
|
if (librarySelect.value) {
|
|
switchLibrary(librarySelect.value);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
}));
|
|
|
|
Alpine.data("seriesDetailPage", () => ({
|
|
initSeriesDetailPage() {
|
|
const librarySelect = document.getElementById(
|
|
"library-select",
|
|
) as HTMLSelectElement;
|
|
if (librarySelect) {
|
|
librarySelect.addEventListener("change", () => {
|
|
if (librarySelect.value) {
|
|
const url = new URL(window.location.href);
|
|
const currentLib = url.searchParams.get("library_id");
|
|
if (currentLib === librarySelect.value) return;
|
|
url.searchParams.set("library_id", librarySelect.value);
|
|
window.location.href = url.toString();
|
|
}
|
|
});
|
|
}
|
|
},
|
|
}));
|