fix(web,api): UI fiche livre et lecteur — progression, locators EPUB/PDF, styles
- web: BookPage/ReaderPage enrichis, sauvegarde de progression lecteur - web: locators EPUB/PDF normalisés (+ tests), EpubReader/PdfReader ajustés - web: nginx et service worker ajustés - api: progress — corrections contrôleur/service Refs: #13
This commit is contained in:
@ -17,7 +17,7 @@ export class ProgressController {
|
||||
|
||||
@Get(":bookId")
|
||||
get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) {
|
||||
return this.progress.get(user.id, Number(bookId));
|
||||
return this.progress.find(user.id, Number(bookId));
|
||||
}
|
||||
|
||||
@Put(":bookId")
|
||||
|
||||
@ -30,6 +30,14 @@ export class ProgressService {
|
||||
}
|
||||
|
||||
get(userId: number, bookId: number) {
|
||||
const row = this.find(userId, bookId);
|
||||
if (!row) {
|
||||
throw new NotFoundException("Progress not found");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
find(userId: number, bookId: number) {
|
||||
const row = this.database.db
|
||||
.select({
|
||||
bookId: progress.bookId,
|
||||
@ -40,10 +48,7 @@ export class ProgressService {
|
||||
.from(progress)
|
||||
.where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`)
|
||||
.get();
|
||||
if (!row) {
|
||||
throw new NotFoundException("Progress not found");
|
||||
}
|
||||
return row;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
continueReading(userId: number) {
|
||||
|
||||
@ -4,26 +4,41 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://api:3000/auth/;
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
try_files /sw.js =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ^~ /auth {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://api:3000/admin/;
|
||||
location ^~ /admin {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /books/ {
|
||||
proxy_pass http://api:3000/books/;
|
||||
location ^~ /books {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /progress/ {
|
||||
proxy_pass http://api:3000/progress/;
|
||||
location ^~ /progress {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = "readabook-shell-v1";
|
||||
const SHELL = ["/", "/home", "/manifest.webmanifest", "/icons/readabook.svg"];
|
||||
const CACHE_NAME = "readabook-shell-v2";
|
||||
const SHELL = ["/", "/index.html", "/manifest.webmanifest", "/icons/readabook.svg"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||
@ -18,5 +18,19 @@ self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET" || ["/auth", "/admin", "/books", "/progress"].some((path) => url.pathname.startsWith(path))) {
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((hit) => hit || caches.match("/"))));
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put("/index.html", copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match("/index.html").then((hit) => hit || caches.match("/")))
|
||||
);
|
||||
return;
|
||||
}
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((hit) => hit || fetch(event.request))
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,27 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BookOpen, LibraryBig } from "lucide-react";
|
||||
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
export function BookPage({ bookId }: { bookId: number }) {
|
||||
const [book, setBook] = useState<BookDto | null>(null);
|
||||
const [progress, setProgress] = useState<ProgressDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function loadBook() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const [nextBook, nextProgress] = await Promise.all([api.book(bookId), api.progress(bookId)]);
|
||||
setBook(nextBook);
|
||||
setProgress(nextProgress);
|
||||
} catch (loadError) {
|
||||
const fallback = getApiFallback<BookDto>(loadError);
|
||||
setBook(fallback ?? null);
|
||||
setProgress(null);
|
||||
setError(fallback ? "Fiche chargee en mode secours." : "Fiche livre indisponible.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.book(bookId), api.progress(bookId)]).then(([nextBook, nextProgress]) => {
|
||||
loadBook().finally(() => {
|
||||
if (!alive) return;
|
||||
setBook(nextBook);
|
||||
setProgress(nextProgress);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [bookId]);
|
||||
|
||||
if (!book) return <LoadingState />;
|
||||
if (loading && !book) return <LoadingState />;
|
||||
if (!book) {
|
||||
return (
|
||||
<Panel>
|
||||
<EmptyState title="Fiche introuvable" detail={error ?? "Le catalogue n'a pas renvoye cet ouvrage."} />
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={17} />
|
||||
Reessayer
|
||||
</button>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="book-detail">
|
||||
@ -29,6 +57,7 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={72} />}
|
||||
</section>
|
||||
<Panel className="book-facts">
|
||||
<ErrorRibbon message={error} />
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
@ -46,6 +75,12 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
<LibraryBig size={18} />
|
||||
Rayon
|
||||
</button>
|
||||
{error && (
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
<RotateCcw size={18} />
|
||||
Reessayer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
@ -1,54 +1,84 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { ArrowLeft, RotateCcw, Save } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { LoadingState, Meter } from "../components/ui";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { ErrorRibbon, Meter } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
import { EpubReader } from "../reader/EpubReader";
|
||||
import { parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||
import { PdfReader } from "../reader/PdfReader";
|
||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||
|
||||
export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
const [book, setBook] = useState<BookDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [page, setPage] = useState(1);
|
||||
const { progress, saving, save } = useReaderProgress(bookId);
|
||||
const { progress, saving, error: progressError, save } = useReaderProgress(bookId);
|
||||
|
||||
async function loadBook() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setBook(await api.book(bookId));
|
||||
} catch (loadError) {
|
||||
const fallback = getApiFallback<BookDto>(loadError);
|
||||
setBook(fallback ?? null);
|
||||
setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.book(bookId).then(setBook);
|
||||
void loadBook();
|
||||
}, [bookId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress?.locator.startsWith("pdf:page:")) setPage(Number(progress.locator.split(":").at(-1)) || 1);
|
||||
const nextPage = parsePdfPageLocator(progress?.locator);
|
||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||
}, [progress]);
|
||||
|
||||
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
||||
const savePdfPage = useCallback(
|
||||
(nextPage: number, pages: number) => {
|
||||
setPage(nextPage);
|
||||
void save(`pdf:page:${nextPage}`, Math.round((nextPage / pages) * 100));
|
||||
void save(`pdf:page:${nextPage}`, pdfPagePercent(nextPage, pages));
|
||||
},
|
||||
[save]
|
||||
);
|
||||
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
||||
|
||||
if (!book) return <LoadingState label="Ouverture du lecteur" />;
|
||||
|
||||
return (
|
||||
<div className="reader-page">
|
||||
<header className="reader-topbar">
|
||||
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
||||
<button className="ghost-button" onClick={() => navigate(book ? `/book/${book.id}` : "/home")}>
|
||||
<ArrowLeft size={17} />
|
||||
Fiche
|
||||
</button>
|
||||
<div>
|
||||
<strong>{book.title}</strong>
|
||||
<span>{saving ? "Sauvegarde" : "Progression synchronisee"}</span>
|
||||
<strong>{book?.title ?? "Ouverture du lecteur"}</strong>
|
||||
<span>{loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<button className="ghost-button icon-only" onClick={() => void loadBook()} aria-label="Reessayer">
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<Save size={18} />
|
||||
)}
|
||||
</header>
|
||||
<ErrorRibbon message={error ?? progressError} />
|
||||
<Meter value={progress?.percent ?? 0} />
|
||||
{book.format === "pdf" ? (
|
||||
<PdfReader url={fileUrl} page={page} onPageChange={savePdfPage} />
|
||||
{!book ? (
|
||||
<div className="reader-fallback">
|
||||
<span>{error ?? "Chargement du livre."}</span>
|
||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
) : book.format === "pdf" ? (
|
||||
<PdfReader url={fileUrl} page={page} onPageCommit={savePdfPage} />
|
||||
) : (
|
||||
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
|
||||
)}
|
||||
|
||||
@ -7,6 +7,7 @@ type FoliateModule = {
|
||||
|
||||
export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [frameKey, setFrameKey] = useState(0);
|
||||
const [status, setStatus] = useState("Ouverture EPUB");
|
||||
|
||||
useEffect(() => {
|
||||
@ -17,7 +18,6 @@ export function EpubReader({ url, locator, onLocatorChange }: { url: string; loc
|
||||
if (cancelled || !hostRef.current) return;
|
||||
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
|
||||
setStatus("EPUB pret");
|
||||
onLocatorChange(locator ?? "epub:start", locator ? 35 : 1);
|
||||
} catch {
|
||||
setStatus("Apercu EPUB indisponible dans ce navigateur");
|
||||
}
|
||||
@ -26,12 +26,20 @@ export function EpubReader({ url, locator, onLocatorChange }: { url: string; loc
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locator, onLocatorChange, url]);
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="epub-reader" ref={hostRef}>
|
||||
<iframe title="EPUB" src={url} />
|
||||
<div className="reader-fallback">{status}</div>
|
||||
<iframe key={frameKey} title="EPUB" src={url} />
|
||||
<div className="reader-fallback">
|
||||
<span>{status}</span>
|
||||
<button className="ghost-button" onClick={() => onLocatorChange(locator ?? "epub:start", locator ? 35 : 1)}>
|
||||
Marquer la position
|
||||
</button>
|
||||
<button className="ghost-button" onClick={() => setFrameKey((value) => value + 1)}>
|
||||
Recharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,15 +4,17 @@ import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
|
||||
export function PdfReader({ url, page, onPageChange }: { url: string; page: number; onPageChange: (page: number, pages: number) => void }) {
|
||||
export function PdfReader({ url, page, onPageCommit }: { url: string; page: number; onPageCommit: (page: number, pages: number) => void }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [error, setError] = useState<string>();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function render() {
|
||||
try {
|
||||
setError(undefined);
|
||||
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||
const document = await loadingTask.promise;
|
||||
if (cancelled) return;
|
||||
@ -26,7 +28,6 @@ export function PdfReader({ url, page, onPageChange }: { url: string; page: numb
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
|
||||
onPageChange(Math.max(1, Math.min(page, document.numPages)), document.numPages);
|
||||
} catch (renderError) {
|
||||
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
|
||||
}
|
||||
@ -35,19 +36,28 @@ export function PdfReader({ url, page, onPageChange }: { url: string; page: numb
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url, page, onPageChange]);
|
||||
}, [url, page, attempt]);
|
||||
|
||||
return (
|
||||
<div className="pdf-reader">
|
||||
{error ? <div className="reader-fallback">{error}</div> : <canvas ref={canvasRef} />}
|
||||
{error ? (
|
||||
<div className="reader-fallback">
|
||||
<span>{error}</span>
|
||||
<button className="ghost-button" onClick={() => setAttempt((value) => value + 1)}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<canvas ref={canvasRef} />
|
||||
)}
|
||||
<div className="reader-stepper">
|
||||
<button className="ghost-button" onClick={() => onPageChange(Math.max(1, page - 1), pages)}>
|
||||
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)}>
|
||||
Precedent
|
||||
</button>
|
||||
<span>
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button className="ghost-button" onClick={() => onPageChange(Math.min(pages, page + 1), pages)}>
|
||||
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)}>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
19
apps/web/src/reader/locators.test.ts
Normal file
19
apps/web/src/reader/locators.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePdfPageLocator, pdfPagePercent } from "./locators";
|
||||
|
||||
describe("reader locators", () => {
|
||||
it("parses valid PDF page locators", () => {
|
||||
expect(parsePdfPageLocator("pdf:page:12")).toBe(12);
|
||||
});
|
||||
|
||||
it("ignores invalid PDF locators", () => {
|
||||
expect(parsePdfPageLocator("epub:start")).toBeNull();
|
||||
expect(parsePdfPageLocator("pdf:page:0")).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds PDF page percentages", () => {
|
||||
expect(pdfPagePercent(2, 4)).toBe(50);
|
||||
expect(pdfPagePercent(8, 4)).toBe(100);
|
||||
expect(pdfPagePercent(1, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
10
apps/web/src/reader/locators.ts
Normal file
10
apps/web/src/reader/locators.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export function parsePdfPageLocator(locator?: string | null): number | null {
|
||||
if (!locator?.startsWith("pdf:page:")) return null;
|
||||
const value = Number(locator.split(":").at(-1));
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function pdfPagePercent(page: number, pages: number): number {
|
||||
if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round((page / pages) * 100)));
|
||||
}
|
||||
@ -1,21 +1,42 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ProgressDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
|
||||
export function useReaderProgress(bookId: number) {
|
||||
const [progress, setProgress] = useState<ProgressDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
api.progress(bookId).then(setProgress);
|
||||
let alive = true;
|
||||
api
|
||||
.progress(bookId)
|
||||
.then((next) => {
|
||||
if (alive) setProgress(next);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setError("Progression indisponible.");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [bookId]);
|
||||
|
||||
const save = useCallback(
|
||||
async (locator: string, percent: number) => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const next = await api.saveProgress(bookId, { locator, percent });
|
||||
setProgress(next);
|
||||
} catch (saveError) {
|
||||
const fallback = getApiFallback<ProgressDto>(saveError);
|
||||
if (fallback) {
|
||||
setProgress(fallback);
|
||||
setError("Progression conservee localement, synchronisation a retenter.");
|
||||
} else {
|
||||
setError("Sauvegarde de progression impossible.");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@ -23,5 +44,5 @@ export function useReaderProgress(bookId: number) {
|
||||
[bookId]
|
||||
);
|
||||
|
||||
return { progress, saving, save };
|
||||
return { progress, saving, error, save };
|
||||
}
|
||||
|
||||
@ -150,9 +150,10 @@ h2 {
|
||||
|
||||
.book-card {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(82px, 96px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 220px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
@ -163,7 +164,10 @@ h2 {
|
||||
.book-portrait {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 156px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
aspect-ratio: 2 / 3;
|
||||
min-height: 0;
|
||||
border: 1px solid rgba(213, 168, 77, 0.35);
|
||||
border-radius: 6px;
|
||||
background:
|
||||
@ -182,6 +186,13 @@ h2 {
|
||||
.book-card h3 {
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.book-card-body {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.book-card p,
|
||||
@ -211,6 +222,19 @@ h2 {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.book-card-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.book-card-actions .ghost-button,
|
||||
.book-card-actions .primary-button {
|
||||
flex: 1 1 86px;
|
||||
min-width: 0;
|
||||
padding-inline: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.format-pill {
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
@ -440,6 +464,11 @@ input {
|
||||
}
|
||||
|
||||
.reader-fallback {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
@ -511,7 +540,11 @@ input {
|
||||
}
|
||||
|
||||
.book-card {
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(72px, 82px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.book-card-actions .ghost-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-table > div,
|
||||
|
||||
Reference in New Issue
Block a user