feat(api,web): modélisation série + volume et parcours de série
- Table series + colonnes seriesId/volumeNumber/volumeLabel sur les livres, extraction souple des numéros (T03, 003, etc.) via extract-series-volume - Contrôleur et page de série, badge volume sur les cartes de livre, styles associés (fond/clarté série, ajustements globaux) - Statuts de scan/enrichissement et provenance des métadonnées en base Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -12,6 +12,7 @@ import { LoginPage } from "./pages/LoginPage";
|
||||
import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { ReaderPage } from "./pages/ReaderPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { SeriesPage } from "./pages/SeriesPage";
|
||||
import { SetupPage } from "./pages/SetupPage";
|
||||
import { navigate, parseRoute, type Route } from "./router";
|
||||
|
||||
@ -24,6 +25,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
||||
<HomePage />
|
||||
) : route.name === "library" ? (
|
||||
<LibraryPage libraryId={route.libraryId} />
|
||||
) : route.name === "catalogSeries" ? (
|
||||
<SeriesPage seriesName={route.seriesName} />
|
||||
) : route.name === "book" ? (
|
||||
<BookPage bookId={route.bookId} />
|
||||
) : route.name === "reader" ? (
|
||||
|
||||
@ -3,6 +3,25 @@ import type { ContinueItem } from "./types";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
type BookPipelineStatus = "idle" | "running" | "succeeded" | "failed";
|
||||
type BookMetadataStatus = "enriched" | "partial" | "none";
|
||||
type MockBookDto = Omit<BookDto, "metadataStatus" | "metadataProvenance" | "scanStatus" | "enrichmentStatus"> & {
|
||||
metadataStatus?: BookMetadataStatus;
|
||||
metadataProvenance?: Record<string, string>;
|
||||
scanStatus?: BookPipelineStatus;
|
||||
enrichmentStatus?: BookPipelineStatus;
|
||||
};
|
||||
|
||||
function mockBook(book: MockBookDto): BookDto {
|
||||
return {
|
||||
metadataStatus: "partial",
|
||||
metadataProvenance: { local: "fixture" },
|
||||
scanStatus: "idle",
|
||||
enrichmentStatus: "idle",
|
||||
...book
|
||||
} as BookDto;
|
||||
}
|
||||
|
||||
export const mockUser: UserDto = {
|
||||
id: 1,
|
||||
email: "admin@readabook.local",
|
||||
@ -17,7 +36,7 @@ export const mockLibraries: LibraryDto[] = [
|
||||
];
|
||||
|
||||
export const mockBooks: BookDto[] = [
|
||||
{
|
||||
mockBook({
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
title: "L'Herbier des machines",
|
||||
@ -35,8 +54,8 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
}),
|
||||
mockBook({
|
||||
id: 2,
|
||||
libraryId: 2,
|
||||
title: "Cartographie des songes",
|
||||
@ -54,8 +73,8 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
}),
|
||||
mockBook({
|
||||
id: 3,
|
||||
libraryId: 2,
|
||||
title: "Les vitrines de verre",
|
||||
@ -73,8 +92,8 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
}),
|
||||
mockBook({
|
||||
id: 4,
|
||||
libraryId: 2,
|
||||
title: "Cabinet noir",
|
||||
@ -92,7 +111,7 @@ export const mockBooks: BookDto[] = [
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
export const mockProgress: ProgressDto[] = [
|
||||
|
||||
@ -36,3 +36,17 @@ export type ReaderPreferencesDto = {
|
||||
mode: ReaderMode;
|
||||
fit?: ReaderFit;
|
||||
};
|
||||
|
||||
export function hasActiveCoverWork(jobs: JobDto[]) {
|
||||
return jobs.some((job) => {
|
||||
if (job.status !== "queued" && job.status !== "running") return false;
|
||||
const type = job.type.toLowerCase();
|
||||
return type.includes("scan") || type.includes("enrich") || type.includes("metadata") || type.includes("cover");
|
||||
});
|
||||
}
|
||||
|
||||
export function isBookCoverUpdating(book: BookDto, fallbackActive = false) {
|
||||
const statuses = book as BookDto & { scanStatus?: string; enrichmentStatus?: string };
|
||||
if (statuses.scanStatus === "running" || statuses.enrichmentStatus === "running") return true;
|
||||
return statuses.scanStatus === undefined && statuses.enrichmentStatus === undefined && fallbackActive;
|
||||
}
|
||||
|
||||
131
apps/web/src/book/metadata.test.ts
Normal file
131
apps/web/src/book/metadata.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import {
|
||||
bookCardVolumeLabel,
|
||||
bookDisplayTitle,
|
||||
bookMetadataSourceSummary,
|
||||
bookMetadataStateLabel,
|
||||
bookSeriesInfo,
|
||||
bookSeriesLabel,
|
||||
bookVolumeLabel,
|
||||
displayPublishedDate,
|
||||
jobDigestSummary
|
||||
} from "./metadata";
|
||||
|
||||
type BookFixture = BookDto & {
|
||||
metadataStatus: "enriched" | "partial" | "none";
|
||||
metadataProvenance: Record<string, string>;
|
||||
scanStatus: "idle" | "running" | "succeeded" | "failed";
|
||||
enrichmentStatus: "idle" | "running" | "succeeded" | "failed";
|
||||
};
|
||||
|
||||
const baseBook: BookFixture = {
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
title: "Livre test",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
format: "epub",
|
||||
filePath: "/books/test.epub",
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenance: {},
|
||||
scanStatus: "idle",
|
||||
enrichmentStatus: "idle",
|
||||
fileSize: 1,
|
||||
fileMtime: "2026-08-23T00:00:00.000Z",
|
||||
createdAt: "2026-08-23T00:00:00.000Z",
|
||||
updatedAt: "2026-08-23T00:00:00.000Z"
|
||||
};
|
||||
|
||||
describe("book metadata presentation", () => {
|
||||
it("labels externally enriched books", () => {
|
||||
const book: BookFixture = { ...baseBook, metadataStatus: "enriched", enrichmentStatus: "succeeded" };
|
||||
expect(bookMetadataStateLabel(book)).toBe("enrichi");
|
||||
expect(bookMetadataSourceSummary(book)).toBe("source locale + enrichissement externe");
|
||||
});
|
||||
|
||||
it("labels locally discovered metadata as partial", () => {
|
||||
const book: BookFixture = { ...baseBook, metadataStatus: "partial", author: "Ada", publishedDate: "1998", scanStatus: "succeeded" };
|
||||
expect(bookMetadataStateLabel(book)).toBe("partiel");
|
||||
expect(bookMetadataSourceSummary(book)).toBe("source locale uniquement");
|
||||
});
|
||||
|
||||
it("labels books without exploitable metadata as missing", () => {
|
||||
expect(bookMetadataStateLabel(baseBook)).toBe("non enrichi");
|
||||
expect(bookMetadataSourceSummary(baseBook)).toBe("metadata indisponible");
|
||||
});
|
||||
|
||||
it("accepts optional series fields when the backend exposes them", () => {
|
||||
const book = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeNumber: 2 } as BookDto & { series: string; volumeNumber: number };
|
||||
expect(bookSeriesLabel(book)).toBe("Cycle · Volume 2");
|
||||
expect(bookDisplayTitle(book)).toBe("Cycle");
|
||||
expect(bookVolumeLabel(book)).toBe("Volume 2");
|
||||
});
|
||||
|
||||
it("uses backend series objects and normalized backend volume labels", () => {
|
||||
const book = {
|
||||
...baseBook,
|
||||
title: "Daredevil",
|
||||
series: { id: 1, title: "Daredevil", normalizedTitle: "daredevil", description: null, publisher: null, createdAt: baseBook.createdAt, updatedAt: baseBook.updatedAt },
|
||||
volumeNumber: 1,
|
||||
volumeLabel: "001"
|
||||
} as BookDto;
|
||||
expect(bookDisplayTitle(book)).toBe("Daredevil");
|
||||
expect(bookVolumeLabel(book)).toBe("#1");
|
||||
expect(bookSeriesLabel(book)).toBe("Daredevil · #1");
|
||||
});
|
||||
|
||||
it("uses compact and unambiguous volume labels on book cards", () => {
|
||||
expect(bookCardVolumeLabel({ ...baseBook, title: "Solo Leveling T03" })).toBe("T. 3");
|
||||
expect(bookCardVolumeLabel({ ...baseBook, title: "Archive Volume 12" })).toBe("T. 12");
|
||||
expect(bookCardVolumeLabel({ ...baseBook, title: "Daredevil #6" })).toBe("#6");
|
||||
});
|
||||
|
||||
it("hides book card volume labels when the number is absent or ambiguous", () => {
|
||||
const ambiguousBook = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeLabel: "Tome final" } as BookDto & { series: string; volumeLabel: string };
|
||||
expect(bookCardVolumeLabel(ambiguousBook)).toBeNull();
|
||||
expect(bookCardVolumeLabel({ ...baseBook, title: "Livre sans tome" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps admin job digest synthetic", () => {
|
||||
expect(
|
||||
jobDigestSummary({
|
||||
id: 1,
|
||||
type: "metadata-enrich",
|
||||
status: "succeeded",
|
||||
detail: null,
|
||||
error: null,
|
||||
createdAt: baseBook.createdAt,
|
||||
updatedAt: baseBook.updatedAt
|
||||
})
|
||||
).toBe("enrichissement externe");
|
||||
});
|
||||
|
||||
it("hides sentinel and absent publication dates", () => {
|
||||
expect(displayPublishedDate("0101-01-01T00:00:00+00:00")).toBeNull();
|
||||
expect(displayPublishedDate(null)).toBeNull();
|
||||
expect(displayPublishedDate("")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders only the credible publication year", () => {
|
||||
expect(displayPublishedDate("2007")).toBe("2007");
|
||||
expect(displayPublishedDate("2007-07-21T00:00:00+00:00")).toBe("2007");
|
||||
expect(displayPublishedDate("first published in 1998")).toBe("1998");
|
||||
});
|
||||
|
||||
it("normalizes flexible series and volume suffixes from titles", () => {
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil 001" })).toEqual({ title: "Daredevil", volumeLabel: "#1", volumeNumber: 1 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil #6" })).toEqual({ title: "Daredevil", volumeLabel: "#6", volumeNumber: 6 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Solo Leveling T03" })).toEqual({ title: "Solo Leveling", volumeLabel: "Tome 3", volumeNumber: 3 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Eyeshield 21 T02" })).toEqual({ title: "Eyeshield 21", volumeLabel: "Tome 2", volumeNumber: 2 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Archive Tome 3" })).toEqual({ title: "Archive", volumeLabel: "Tome 3", volumeNumber: 3 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Archive Volume 3" })).toEqual({ title: "Archive", volumeLabel: "Volume 3", volumeNumber: 3 });
|
||||
expect(bookSeriesInfo({ ...baseBook, title: "Archive Issue 6" })).toEqual({ title: "Archive", volumeLabel: "#6", volumeNumber: 6 });
|
||||
});
|
||||
});
|
||||
171
apps/web/src/book/metadata.ts
Normal file
171
apps/web/src/book/metadata.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import type { BookDto, JobDto } from "@readabook/shared";
|
||||
|
||||
export type BookMetadataState = "enriched" | "partial" | "missing";
|
||||
|
||||
const earliestCrediblePublishedYear = 1450;
|
||||
|
||||
export type BookSeriesInfo = {
|
||||
title: string;
|
||||
volumeLabel: string | null;
|
||||
volumeNumber: number | null;
|
||||
};
|
||||
|
||||
type ExtendedBookDto = BookDto & {
|
||||
scanStatus?: "idle" | "running" | "succeeded" | "failed";
|
||||
enrichmentStatus?: "idle" | "running" | "succeeded" | "failed";
|
||||
series?: string | { title?: string | null } | null;
|
||||
seriesTitle?: string | null;
|
||||
collection?: string | null;
|
||||
volumeLabel?: string | null;
|
||||
seriesIndex?: string | number | null;
|
||||
seriesNumber?: string | number | null;
|
||||
volume?: string | number | null;
|
||||
volumeNumber?: string | number | null;
|
||||
issue?: string | number | null;
|
||||
issueNumber?: string | number | null;
|
||||
};
|
||||
|
||||
function hasValue(value: unknown): value is string | number {
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function bookSeriesLabel(book: BookDto): string | null {
|
||||
const series = bookSeriesInfo(book);
|
||||
if (!series) return null;
|
||||
return [series.title, series.volumeLabel].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function numericValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
const match = value.trim().match(/\d+/);
|
||||
if (!match) return null;
|
||||
const parsed = Number(match[0]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeVolumeLabel(value: unknown, fallbackKind: "tome" | "volume" | "issue" = "volume"): { label: string; number: number | null } | null {
|
||||
if (!hasValue(value)) return null;
|
||||
const raw = String(value).trim();
|
||||
const number = numericValue(raw);
|
||||
if (!number) return null;
|
||||
if (/^(t|tome)\s*0*\d+$/i.test(raw)) return { label: `Tome ${number}`, number };
|
||||
if (/^(vol\.?|volume)\s*0*\d+$/i.test(raw)) return { label: `Volume ${number}`, number };
|
||||
if (/^(#|issue)\s*0*\d+$/i.test(raw)) return { label: `#${number}`, number };
|
||||
if (/^0\d{2,}$/.test(raw)) return { label: `#${number}`, number };
|
||||
if (fallbackKind === "tome") return { label: `Tome ${number}`, number };
|
||||
if (fallbackKind === "issue") return { label: `#${number}`, number };
|
||||
return { label: `Volume ${number}`, number };
|
||||
}
|
||||
|
||||
function titleVolumeInfo(title: string): BookSeriesInfo | null {
|
||||
const trimmed = title.trim();
|
||||
const patterns: Array<{ pattern: RegExp; kind: "tome" | "volume" | "issue" }> = [
|
||||
{ pattern: /^(.+?)\s+(T|Tome)\s*0*(\d+)$/i, kind: "tome" },
|
||||
{ pattern: /^(.+?)\s+(Vol\.?|Volume)\s*0*(\d+)$/i, kind: "volume" },
|
||||
{ pattern: /^(.+?)\s+(#|Issue)\s*0*(\d+)$/i, kind: "issue" },
|
||||
{ pattern: /^(.+?)\s+0*(\d{3})$/i, kind: "issue" }
|
||||
];
|
||||
for (const { pattern, kind } of patterns) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (!match) continue;
|
||||
const titlePart = match[1]?.trim();
|
||||
const number = Number(match[3] ?? match[2]);
|
||||
if (!titlePart || !Number.isFinite(number)) continue;
|
||||
const normalized = normalizeVolumeLabel(number, kind);
|
||||
if (!normalized) continue;
|
||||
return { title: titlePart, volumeLabel: normalized.label, volumeNumber: normalized.number };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function bookSeriesInfo(book: BookDto): BookSeriesInfo | null {
|
||||
const extended = book as ExtendedBookDto;
|
||||
const seriesObjectTitle =
|
||||
extended.series && typeof extended.series === "object" && hasValue(extended.series.title) ? extended.series.title : null;
|
||||
const series = [seriesObjectTitle, extended.series, extended.seriesTitle, extended.collection].find(hasValue);
|
||||
if (series) {
|
||||
const explicitLabel = normalizeVolumeLabel(extended.volumeLabel);
|
||||
const issue = normalizeVolumeLabel([extended.issueNumber, extended.issue, extended.seriesNumber].find(hasValue), "issue");
|
||||
const volume = normalizeVolumeLabel([extended.volumeNumber, extended.volume, extended.seriesIndex].find(hasValue), "volume");
|
||||
const position = explicitLabel ?? issue ?? volume;
|
||||
return {
|
||||
title: String(series),
|
||||
volumeLabel: position?.label ?? null,
|
||||
volumeNumber: position?.number ?? null
|
||||
};
|
||||
}
|
||||
return titleVolumeInfo(book.title);
|
||||
}
|
||||
|
||||
export function bookDisplayTitle(book: BookDto): string {
|
||||
return bookSeriesInfo(book)?.title ?? book.title;
|
||||
}
|
||||
|
||||
export function bookVolumeLabel(book: BookDto): string | null {
|
||||
return bookSeriesInfo(book)?.volumeLabel ?? null;
|
||||
}
|
||||
|
||||
export function bookCardVolumeLabel(book: BookDto): string | null {
|
||||
const series = bookSeriesInfo(book);
|
||||
if (!series?.volumeNumber) return null;
|
||||
if (!series.volumeLabel) return null;
|
||||
if (series.volumeLabel.startsWith("#")) return series.volumeLabel;
|
||||
return `T. ${series.volumeNumber}`;
|
||||
}
|
||||
|
||||
export function displayPublishedDate(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const yearMatch = trimmed.match(/\b(\d{4})\b/);
|
||||
if (!yearMatch) return null;
|
||||
const year = Number(yearMatch[1]);
|
||||
const nextYear = new Date().getFullYear() + 1;
|
||||
if (!Number.isInteger(year) || year < earliestCrediblePublishedYear || year > nextYear) return null;
|
||||
return String(year);
|
||||
}
|
||||
|
||||
export function usefulMetadataCount(book: BookDto): number {
|
||||
return [
|
||||
book.author,
|
||||
displayPublishedDate(book.publishedDate),
|
||||
book.publisher,
|
||||
book.description,
|
||||
book.isbn13,
|
||||
book.isbn,
|
||||
book.coverPath,
|
||||
bookSeriesLabel(book)
|
||||
].filter(hasValue).length;
|
||||
}
|
||||
|
||||
export function bookMetadataState(book: BookDto): BookMetadataState {
|
||||
const statuses = book as ExtendedBookDto;
|
||||
if (statuses.enrichmentStatus === "succeeded") return "enriched";
|
||||
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "partial";
|
||||
return "missing";
|
||||
}
|
||||
|
||||
export function bookMetadataStateLabel(book: BookDto): string {
|
||||
const state = bookMetadataState(book);
|
||||
if (state === "enriched") return "enrichi";
|
||||
if (state === "partial") return "partiel";
|
||||
return "non enrichi";
|
||||
}
|
||||
|
||||
export function bookMetadataSourceSummary(book: BookDto): string {
|
||||
const statuses = book as ExtendedBookDto;
|
||||
if (statuses.enrichmentStatus === "running" || statuses.scanStatus === "running") return "mise a jour en cours";
|
||||
if (statuses.enrichmentStatus === "succeeded") return "source locale + enrichissement externe";
|
||||
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "source locale uniquement";
|
||||
return "metadata indisponible";
|
||||
}
|
||||
|
||||
export function jobDigestSummary(job: JobDto): string {
|
||||
const detail = job.detail?.trim();
|
||||
if (detail) return detail;
|
||||
if (job.type.toLowerCase().includes("enrich")) return "enrichissement externe";
|
||||
if (job.type.toLowerCase().includes("scan")) return "source locale";
|
||||
return "travail catalogue";
|
||||
}
|
||||
@ -1,22 +1,35 @@
|
||||
import { BookOpen, Eye } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
|
||||
import { navigate } from "../router";
|
||||
import { FormatPill } from "./ui";
|
||||
|
||||
export function BookCard({ book, compact = false }: { book: BookDto; compact?: boolean }) {
|
||||
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
|
||||
const metadataState = bookMetadataState(book);
|
||||
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||
const volumeLabel = bookCardVolumeLabel(book);
|
||||
|
||||
return (
|
||||
<article className={`book-card ${compact ? "book-card-compact" : ""}`}>
|
||||
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}>
|
||||
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={34} />}
|
||||
{coverLoading && <span className="cover-loading" aria-label="Jaquette en cours de mise à jour" />}
|
||||
</button>
|
||||
<div className="book-card-body">
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
|
||||
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
|
||||
</div>
|
||||
<h3>{book.title}</h3>
|
||||
<h3>{bookDisplayTitle(book)}</h3>
|
||||
<p>{book.author ?? "Auteur inconnu"}</p>
|
||||
{(volumeLabel || publishedDate) && (
|
||||
<p className="book-card-submeta">
|
||||
{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||
{!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
|
||||
<div className="book-card-actions">
|
||||
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
||||
|
||||
@ -3,6 +3,7 @@ import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { cleanBookDescription } from "../book/description";
|
||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
@ -52,6 +53,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const series = bookSeriesInfo(book);
|
||||
const metadataState = bookMetadataState(book);
|
||||
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||
const detailFacts = [
|
||||
{ label: "Auteur", value: book.author },
|
||||
{ label: "Date", value: publishedDate },
|
||||
{ label: "Serie", value: series?.title },
|
||||
{ label: "Position", value: series?.volumeLabel },
|
||||
{ label: "Editeur", value: book.publisher },
|
||||
{ label: "ISBN", value: book.isbn13 ?? book.isbn }
|
||||
].filter((fact) => fact.value);
|
||||
|
||||
return (
|
||||
<div className="book-detail">
|
||||
<section className="book-portrait">
|
||||
@ -62,9 +75,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||
</div>
|
||||
<h1>{book.title}</h1>
|
||||
<h1>{bookDisplayTitle(book)}</h1>
|
||||
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
||||
<dl className="book-fact-list">
|
||||
{detailFacts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="book-description">{cleanBookDescription(book.description)}</p>
|
||||
{progress && <Meter value={progress.percent} />}
|
||||
<div className="book-card-actions">
|
||||
@ -76,6 +98,12 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<LibraryBig size={18} />
|
||||
Rayon
|
||||
</button>
|
||||
{series && (
|
||||
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
|
||||
<LibraryBig size={18} />
|
||||
Serie
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={18} />
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LibraryBig, ScanLine } from "lucide-react";
|
||||
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
|
||||
import { api } from "../api/client";
|
||||
import type { DashboardData } from "../api/types";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating, type DashboardData } from "../api/types";
|
||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookVolumeLabel, displayPublishedDate } from "../book/metadata";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
@ -27,6 +28,7 @@ export function HomePage() {
|
||||
}, []);
|
||||
|
||||
if (!state) return <LoadingState />;
|
||||
const fallbackCoverLoading = hasActiveCoverWork(state.jobs);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@ -49,13 +51,25 @@ export function HomePage() {
|
||||
</div>
|
||||
{state.continueReading.length ? (
|
||||
<div className="continue-grid">
|
||||
{state.continueReading.map((item) => (
|
||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
||||
<strong>{item.book.title}</strong>
|
||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
||||
<Meter value={item.progress.percent} />
|
||||
</button>
|
||||
))}
|
||||
{state.continueReading.map((item) => {
|
||||
const metadataState = bookMetadataState(item.book);
|
||||
const publishedDate = displayPublishedDate(item.book.publishedDate);
|
||||
const volumeLabel = bookVolumeLabel(item.book);
|
||||
return (
|
||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
||||
<span className="continue-cover" aria-hidden="true">
|
||||
{item.book.coverPath ? <img src={api.bookCoverUrl(item.book.id)} alt="" /> : <BookOpen size={22} />}
|
||||
</span>
|
||||
<span className="continue-copy">
|
||||
<strong>{bookDisplayTitle(item.book)}</strong>
|
||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
||||
{(volumeLabel || publishedDate) && <span>{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}</span>}
|
||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(item.book)}</span>
|
||||
<Meter value={item.progress.percent} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
|
||||
@ -79,7 +93,7 @@ export function HomePage() {
|
||||
|
||||
<section className="book-grid span-3">
|
||||
{state.books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BookDto, LibraryDto } from "@readabook/shared";
|
||||
import type { BookDto, JobDto, LibraryDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.books({ libraryId }), api.libraries()]).then(([nextBooks, nextLibraries]) => {
|
||||
Promise.all([api.books({ libraryId }), api.libraries(), api.jobs().catch(() => [])]).then(([nextBooks, nextLibraries, nextJobs]) => {
|
||||
if (!alive) return;
|
||||
setBooks(nextBooks);
|
||||
setLibraries(nextLibraries);
|
||||
setJobs(nextJobs);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
@ -22,6 +25,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
|
||||
if (!books) return <LoadingState />;
|
||||
const library = libraries.find((item) => item.id === libraryId);
|
||||
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@ -37,7 +41,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||
{books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import type { BookDto, JobDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function SearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [books, setBooks] = useState<BookDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
@ -15,7 +17,9 @@ export function SearchPage() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setBooks(nextQuery.trim() ? await api.search(nextQuery.trim()) : await api.books());
|
||||
const [nextBooks, nextJobs] = await Promise.all([nextQuery.trim() ? api.search(nextQuery.trim()) : api.books(), api.jobs().catch(() => [])]);
|
||||
setBooks(nextBooks);
|
||||
setJobs(nextJobs);
|
||||
} catch (loadError) {
|
||||
const fallback = getApiFallback<BookDto[]>(loadError);
|
||||
setBooks(fallback ?? []);
|
||||
@ -25,6 +29,8 @@ export function SearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBooks("");
|
||||
}, []);
|
||||
@ -58,7 +64,7 @@ export function SearchPage() {
|
||||
) : books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{books.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
53
apps/web/src/pages/SeriesPage.tsx
Normal file
53
apps/web/src/pages/SeriesPage.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { bookSeriesInfo } from "../book/metadata";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function SeriesPage({ seriesName }: { seriesName: string }) {
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.books().then((nextBooks) => {
|
||||
if (alive) setBooks(nextBooks);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const seriesBooks = useMemo(() => {
|
||||
const expected = seriesName.trim().toLocaleLowerCase();
|
||||
return (books ?? [])
|
||||
.filter((book) => bookSeriesInfo(book)?.title.trim().toLocaleLowerCase() === expected)
|
||||
.sort((left, right) => (bookSeriesInfo(left)?.volumeNumber ?? Number.MAX_SAFE_INTEGER) - (bookSeriesInfo(right)?.volumeNumber ?? Number.MAX_SAFE_INTEGER));
|
||||
}, [books, seriesName]);
|
||||
|
||||
if (!books) return <LoadingState />;
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-3">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h1>{seriesName}</h1>
|
||||
<p>{seriesBooks.length} volumes reperes</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
{seriesBooks.length ? (
|
||||
<section className="book-grid span-3">
|
||||
{seriesBooks.map((book) => (
|
||||
<BookCard key={book.id} book={book} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<Panel className="span-3">
|
||||
<EmptyState title="Serie introuvable" detail="Les volumes apparaitront ici quand le catalogue exposera leur serie." />
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/router.test.ts
Normal file
9
apps/web/src/router.test.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseRoute } from "./router";
|
||||
|
||||
describe("parseRoute", () => {
|
||||
it("keeps the frontend series view away from the backend /series endpoint", () => {
|
||||
expect(parseRoute("/series")).toEqual({ name: "home" });
|
||||
expect(parseRoute("/catalog/series/Daredevil")).toEqual({ name: "catalogSeries", seriesName: "Daredevil" });
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,7 @@ export type Route =
|
||||
| { name: "setup"; step: string }
|
||||
| { name: "home" }
|
||||
| { name: "library"; libraryId: number }
|
||||
| { name: "catalogSeries"; seriesName: string }
|
||||
| { name: "book"; bookId: number }
|
||||
| { name: "reader"; bookId: number }
|
||||
| { name: "search" }
|
||||
@ -14,6 +15,7 @@ export function parseRoute(pathname = window.location.pathname): Route {
|
||||
if (parts[0] === "login") return { name: "login" };
|
||||
if (parts[0] === "setup") return { name: "setup", step: parts[1] ?? "admin" };
|
||||
if (parts[0] === "library") return { name: "library", libraryId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "catalog" && parts[1] === "series") return { name: "catalogSeries", seriesName: decodeURIComponent(parts[2] ?? "") };
|
||||
if (parts[0] === "book") return { name: "book", bookId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "reader") return { name: "reader", bookId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "search") return { name: "search" };
|
||||
|
||||
@ -173,6 +173,7 @@ h2 {
|
||||
|
||||
.cover-button,
|
||||
.book-portrait {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
@ -187,6 +188,19 @@ h2 {
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.cover-loading {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
bottom: 7px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(247, 240, 223, 0.78);
|
||||
border-top-color: var(--brass);
|
||||
border-radius: 999px;
|
||||
background: rgba(23, 17, 13, 0.62);
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.cover-button img,
|
||||
.book-portrait img {
|
||||
width: 100%;
|
||||
@ -223,6 +237,10 @@ h2 {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.book-card-submeta {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.book-card-meta,
|
||||
.book-card-actions,
|
||||
.section-heading,
|
||||
@ -232,6 +250,12 @@ h2 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.book-card-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.book-card-actions,
|
||||
.section-heading {
|
||||
justify-content: space-between;
|
||||
@ -251,6 +275,7 @@ h2 {
|
||||
}
|
||||
|
||||
.format-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
color: #17110d;
|
||||
@ -259,6 +284,27 @@ h2 {
|
||||
background: var(--brass);
|
||||
}
|
||||
|
||||
.volume-pill {
|
||||
flex: 0 0 auto;
|
||||
max-width: 48px;
|
||||
overflow: hidden;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid rgba(213, 168, 77, 0.56);
|
||||
border-radius: 999px;
|
||||
color: #f5dfaa;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: rgba(213, 168, 77, 0.12);
|
||||
}
|
||||
|
||||
.book-card-language {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.format-pdf {
|
||||
background: var(--lacquer);
|
||||
color: var(--ink);
|
||||
@ -274,6 +320,35 @@ h2 {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.metadata-pill {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metadata-enriched {
|
||||
border-color: rgba(45, 111, 99, 0.72);
|
||||
color: #d8fff5;
|
||||
background: rgba(45, 111, 99, 0.18);
|
||||
}
|
||||
|
||||
.metadata-partial {
|
||||
border-color: rgba(213, 168, 77, 0.68);
|
||||
color: #f5dfaa;
|
||||
background: rgba(213, 168, 77, 0.12);
|
||||
}
|
||||
|
||||
.metadata-missing {
|
||||
border-color: rgba(169, 72, 52, 0.62);
|
||||
color: #f2b8aa;
|
||||
background: rgba(169, 72, 52, 0.12);
|
||||
}
|
||||
|
||||
.continue-grid,
|
||||
.library-list,
|
||||
.job-list {
|
||||
@ -281,9 +356,15 @@ h2 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.continue-tile,
|
||||
.library-list button,
|
||||
.job-list div,
|
||||
.job-list > div,
|
||||
.library-table > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
@ -300,6 +381,62 @@ h2 {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.job-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.job-list time {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.job-copy small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.continue-tile {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.continue-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(213, 168, 77, 0.35);
|
||||
border-radius: 5px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(213, 168, 77, 0.2), rgba(45, 111, 99, 0.22)),
|
||||
var(--paper-soft);
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.continue-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.continue-copy {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.continue-copy > span {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.continue-copy .metadata-pill {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.library-copy {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
@ -523,7 +660,7 @@ select {
|
||||
|
||||
.provider-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 1fr) minmax(190px, 280px) auto;
|
||||
grid-template-columns: minmax(210px, 1fr) minmax(240px, 360px) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
@ -541,6 +678,51 @@ select {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.provider-config {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-state-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-state-line small {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
|
||||
.provider-warning {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(213, 168, 77, 0.45);
|
||||
border-radius: 6px;
|
||||
color: #f5dfaa;
|
||||
background: rgba(213, 168, 77, 0.1);
|
||||
}
|
||||
|
||||
.provider-state-configured {
|
||||
border-color: rgba(45, 111, 99, 0.72);
|
||||
color: #d8fff5;
|
||||
background: rgba(45, 111, 99, 0.18);
|
||||
}
|
||||
|
||||
.provider-state-missing-config {
|
||||
border-color: rgba(213, 168, 77, 0.68);
|
||||
color: #f5dfaa;
|
||||
background: rgba(213, 168, 77, 0.12);
|
||||
}
|
||||
|
||||
.provider-state-limited,
|
||||
.provider-state-error {
|
||||
border-color: rgba(169, 72, 52, 0.62);
|
||||
color: #f2b8aa;
|
||||
background: rgba(169, 72, 52, 0.12);
|
||||
}
|
||||
|
||||
.provider-actions,
|
||||
.save-bar,
|
||||
.save-bar div {
|
||||
@ -635,6 +817,33 @@ select {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.book-fact-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
|
||||
.book-fact-list > div {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.book-fact-list dt {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.book-fact-list dd {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
@ -656,6 +865,11 @@ select {
|
||||
background: #120e0b;
|
||||
}
|
||||
|
||||
.reader-page:fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.reader-topbar {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
@ -687,6 +901,12 @@ select {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reader-toolbar .reader-mode-button {
|
||||
gap: 7px;
|
||||
padding-inline: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reader-toolbar .active {
|
||||
border-color: rgba(213, 168, 77, 0.72);
|
||||
color: var(--brass);
|
||||
@ -730,6 +950,12 @@ select {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pdf-reader-vertical,
|
||||
.cbz-reader-vertical {
|
||||
place-items: start center;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.epub-host {
|
||||
display: grid;
|
||||
width: min(100%, 980px);
|
||||
@ -746,18 +972,34 @@ select {
|
||||
color: #17110d;
|
||||
}
|
||||
|
||||
.pdf-reader canvas,
|
||||
.cbz-reader img {
|
||||
.pdf-page-frame,
|
||||
.comic-page-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 64px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pdf-page-frame canvas {
|
||||
display: block;
|
||||
max-width: min(100%, 980px);
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #f7f0df;
|
||||
}
|
||||
|
||||
.cbz-reader img {
|
||||
width: auto;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
max-width: min(100%, 980px);
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #f7f0df;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@ -767,17 +1009,28 @@ select {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.reader-mode-vertical .pdf-reader canvas,
|
||||
.pdf-strip,
|
||||
.comic-strip {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.reader-mode-vertical .pdf-page-frame canvas,
|
||||
.reader-mode-vertical .cbz-reader img {
|
||||
width: min(100%, 980px);
|
||||
height: auto;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.reader-mode-horizontal .pdf-reader canvas,
|
||||
.reader-mode-horizontal .pdf-page-frame canvas,
|
||||
.reader-mode-horizontal .cbz-reader img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@ -966,6 +1219,7 @@ select {
|
||||
|
||||
.library-table > div,
|
||||
.search-form,
|
||||
.book-fact-list,
|
||||
.automation-grid,
|
||||
.provider-row,
|
||||
.provider-local,
|
||||
|
||||
Reference in New Issue
Block a user