feat(reader): page thumbnails tab + reliable PDF contents
Investigation: the contents drawer read book.toc, which makePDF builds from pdf.getOutline() — verified against the real library PDF (Head First SQL) through the exact vendored pdf.js build AND the exact range transport the browser uses: 18 chapter entries come back. So the source is right; manga-scan PDFs and CBZs simply have no embedded outline, which made Contents look broken exactly where users expect page-based navigation. - TOC now populates eagerly right after the book opens (toggle-time lazy population removed), so an existing outline can never silently miss due to timing; the drawer keeps the honest empty-state text for books without outlines. - New 'Pages' tab in the contents drawer for fixed-layout books: a Kavita-style thumbnail grid (3-up, current page highlighted and scrolled into view, click to jump — recorded on the back-to- location stack). Thumbnails render client-side: PDFs via the in-memory pdf.js document (small viewport render, Alpine.raw unwrap); comics via the page's image blob drawn down to a 110px canvas, then unloading the full-size blob so thumbnailling doesn't hoard page images. Lazy via IntersectionObserver scoped to the drawer's scroll container (200px margin), canvases cached at module level so revisits are instant; failures warn in console and allow retry. The backend /readers/thumbnails endpoint turned out to be an empty stub, so nothing server-side was worth wiring.
This commit is contained in:
1 parent
dc68d03360
commit
fd4c357d39
5 files changed
+212
-3
No files matched your search
+27
-1
@@ -521,8 +521,18 @@ templ drawerHeader(title string) {
|
|||||||
|
|
||||||
templ ReaderTOCDrawer() {
|
templ ReaderTOCDrawer() {
|
||||||
@drawerHeader("Contents")
|
@drawerHeader("Contents")
|
||||||
|
<!-- Fixed-layout books get a Pages (thumbnails) tab; outline TOC may be
|
||||||
|
absent in manga-scan PDFs and CBZs. -->
|
||||||
|
<div class="reader-tabs" x-show="isFixedLayout">
|
||||||
|
<button :class="tocTab === 'contents' ? 'active' : ''" @click="tocTab = 'contents'">
|
||||||
|
Contents <span class="reader-tab-count" x-text="tocItems.length">0</span>
|
||||||
|
</button>
|
||||||
|
<button :class="tocTab === 'pages' ? 'active' : ''" @click="tocTab = 'pages'; initPageThumbs()">
|
||||||
|
Pages <span class="reader-tab-count" x-text="pageThumbList.length || ''">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="reader-drawer-body">
|
<div class="reader-drawer-body">
|
||||||
<nav id="toc-list" class="space-y-1">
|
<nav id="toc-list" class="space-y-1" x-show="tocTab === 'contents' || !isFixedLayout">
|
||||||
<template x-for="item in tocItems" :key="item.href">
|
<template x-for="item in tocItems" :key="item.href">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@@ -536,6 +546,22 @@ templ ReaderTOCDrawer() {
|
|||||||
<p class="text-sm py-2" style="color: var(--text-secondary)">No table of contents</p>
|
<p class="text-sm py-2" style="color: var(--text-secondary)">No table of contents</p>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div id="page-thumb-grid" class="reader-thumb-grid" x-show="isFixedLayout && tocTab === 'pages'">
|
||||||
|
<template x-for="p in pageThumbList" :key="p.index">
|
||||||
|
<button
|
||||||
|
class="reader-thumb"
|
||||||
|
:class="p.index === currentPageIndex ? 'active' : ''"
|
||||||
|
@click="goToPage(p.index)"
|
||||||
|
:title="'Page ' + (p.index + 1)"
|
||||||
|
>
|
||||||
|
<div class="reader-thumb-img" :data-page="p.index"></div>
|
||||||
|
<span class="reader-thumb-num" x-text="p.index + 1">1</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template x-if="pageThumbList.length === 0">
|
||||||
|
<p class="text-sm py-2" style="color: var(--text-secondary)">No pages</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ func ReaderTOCDrawer() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"reader-drawer-body\"><nav id=\"toc-list\" class=\"space-y-1\"><template x-for=\"item in tocItems\" :key=\"item.href\"><a href=\"#\" @click.prevent=\"goToTOCItem(item)\" class=\"block py-1 px-2 rounded hover:bg-gray-700 text-sm\" :style=\"'padding-left: ' + ((item.depth || 0) * 1 + 0.5) + 'rem'\" x-text=\"item.label\"></a></template><template x-if=\"tocItems.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No table of contents</p></template></nav></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<!-- Fixed-layout books get a Pages (thumbnails) tab; outline TOC may be\n\t absent in manga-scan PDFs and CBZs. --><div class=\"reader-tabs\" x-show=\"isFixedLayout\"><button :class=\"tocTab === 'contents' ? 'active' : ''\" @click=\"tocTab = 'contents'\">Contents <span class=\"reader-tab-count\" x-text=\"tocItems.length\">0</span></button> <button :class=\"tocTab === 'pages' ? 'active' : ''\" @click=\"tocTab = 'pages'; initPageThumbs()\">Pages <span class=\"reader-tab-count\" x-text=\"pageThumbList.length || ''\">0</span></button></div><div class=\"reader-drawer-body\"><nav id=\"toc-list\" class=\"space-y-1\" x-show=\"tocTab === 'contents' || !isFixedLayout\"><template x-for=\"item in tocItems\" :key=\"item.href\"><a href=\"#\" @click.prevent=\"goToTOCItem(item)\" class=\"block py-1 px-2 rounded hover:bg-gray-700 text-sm\" :style=\"'padding-left: ' + ((item.depth || 0) * 1 + 0.5) + 'rem'\" x-text=\"item.label\"></a></template><template x-if=\"tocItems.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No table of contents</p></template></nav><div id=\"page-thumb-grid\" class=\"reader-thumb-grid\" x-show=\"isFixedLayout && tocTab === 'pages'\"><template x-for=\"p in pageThumbList\" :key=\"p.index\"><button class=\"reader-thumb\" :class=\"p.index === currentPageIndex ? 'active' : ''\" @click=\"goToPage(p.index)\" :title=\"'Page ' + (p.index + 1)\"><div class=\"reader-thumb-img\" :data-page=\"p.index\"></div><span class=\"reader-thumb-num\" x-text=\"p.index + 1\">1</span></button></template><template x-if=\"pageThumbList.length === 0\"><p class=\"text-sm py-2\" style=\"color: var(--text-secondary)\">No pages</p></template></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const HIGHLIGHT_COLORS = [
|
|||||||
"#ce93d8",
|
"#ce93d8",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Rendered page-thumbnail canvases (page index → canvas). Module-level so
|
||||||
|
// Alpine reactivity never wraps it and revisiting pages is instant.
|
||||||
|
const thumbCache = new Map<number, HTMLCanvasElement>();
|
||||||
|
|
||||||
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
|
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
|
||||||
|
|
||||||
const FONT_MAP: Record<string, string> = {
|
const FONT_MAP: Record<string, string> = {
|
||||||
@@ -418,6 +422,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
searchError: "",
|
searchError: "",
|
||||||
searchGen: 0,
|
searchGen: 0,
|
||||||
pdfPagesCache: null as PdfPageText[] | null,
|
pdfPagesCache: null as PdfPageText[] | null,
|
||||||
|
tocTab: "contents" as string,
|
||||||
|
pageThumbList: [] as { index: number }[],
|
||||||
|
thumbObserver: null as IntersectionObserver | null,
|
||||||
pdfSearchKeys: [] as string[],
|
pdfSearchKeys: [] as string[],
|
||||||
backStack: [] as { cfi?: string; page?: number }[],
|
backStack: [] as { cfi?: string; page?: number }[],
|
||||||
selectionPopover: {
|
selectionPopover: {
|
||||||
@@ -591,6 +598,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
});
|
});
|
||||||
this.renderer = this.view.renderer;
|
this.renderer = this.view.renderer;
|
||||||
this.book = this.view.book;
|
this.book = this.view.book;
|
||||||
|
// Populate the TOC eagerly (PDF outlines and EPUB TOCs alike) so the
|
||||||
|
// drawer never depends on lazy-toggle timing; toggleTOC only opens.
|
||||||
|
if (this.book?.toc?.length) {
|
||||||
|
this.tocItems = this.flattenTOC(this.book.toc);
|
||||||
|
}
|
||||||
this.isFixedLayout = this.view.isFixedLayout;
|
this.isFixedLayout = this.view.isFixedLayout;
|
||||||
if (this.isFixedLayout) {
|
if (this.isFixedLayout) {
|
||||||
// Manga/RTL comics: foliate's goLeft/goRight swap on book.dir === "rtl",
|
// Manga/RTL comics: foliate's goLeft/goRight swap on book.dir === "rtl",
|
||||||
@@ -1682,6 +1694,132 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.tocOpen = false;
|
this.tocOpen = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// ----- page thumbnails (fixed-layout: PDF via pdf.js, comics via the
|
||||||
|
// page image blobs). Lazy-rendered with an IntersectionObserver so only
|
||||||
|
// visible cells pay extraction/render cost. -----
|
||||||
|
get currentPageIndex(): number {
|
||||||
|
return typeof this.renderer?.index === "number" ? this.renderer.index : -1;
|
||||||
|
},
|
||||||
|
initPageThumbs() {
|
||||||
|
if (!this.isFixedLayout || !this.book) return;
|
||||||
|
if (!this.pageThumbList.length) {
|
||||||
|
const sections = (this.book as any).sections ?? [];
|
||||||
|
this.pageThumbList = sections.map((_s: any, i: number) => ({ index: i }));
|
||||||
|
}
|
||||||
|
(this as any).$nextTick(() => {
|
||||||
|
const grid = document.getElementById("page-thumb-grid");
|
||||||
|
if (!grid || this.thumbObserver) {
|
||||||
|
this.resumeThumbObserver();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = grid.closest(".reader-drawer-body");
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue;
|
||||||
|
io.unobserve(entry.target);
|
||||||
|
const page = parseInt(
|
||||||
|
(entry.target as HTMLElement).dataset.page ?? "",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (!isNaN(page)) this.renderPageThumb(page, entry.target as HTMLElement);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: root instanceof Element ? root : null, rootMargin: "200px" },
|
||||||
|
);
|
||||||
|
this.thumbObserver = io;
|
||||||
|
for (const el of Array.from(
|
||||||
|
grid.querySelectorAll<HTMLElement>(".reader-thumb-img"),
|
||||||
|
)) {
|
||||||
|
io.observe(el);
|
||||||
|
}
|
||||||
|
this.scrollToCurrentThumb();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resumeThumbObserver() {
|
||||||
|
const grid = document.getElementById("page-thumb-grid");
|
||||||
|
if (grid && this.thumbObserver) {
|
||||||
|
for (const el of Array.from(
|
||||||
|
grid.querySelectorAll<HTMLElement>(
|
||||||
|
".reader-thumb-img:not([data-done])",
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
this.thumbObserver.observe(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.scrollToCurrentThumb();
|
||||||
|
},
|
||||||
|
scrollToCurrentThumb() {
|
||||||
|
const cur = document.querySelector(
|
||||||
|
"#page-thumb-grid .reader-thumb.active",
|
||||||
|
);
|
||||||
|
cur?.scrollIntoView({ block: "center" });
|
||||||
|
},
|
||||||
|
async renderPageThumb(page: number, container: HTMLElement) {
|
||||||
|
if (container.dataset.done) return;
|
||||||
|
container.dataset.done = "1";
|
||||||
|
try {
|
||||||
|
const cached = thumbCache.get(page);
|
||||||
|
if (cached) {
|
||||||
|
container.appendChild(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawBook = (Alpine as any).raw
|
||||||
|
? (Alpine as any).raw(this.book)
|
||||||
|
: this.book;
|
||||||
|
if (this.isPDF && rawBook?.pdf) {
|
||||||
|
const pdfPage = await rawBook.pdf.getPage(page + 1);
|
||||||
|
const base = pdfPage.getViewport({ scale: 1 });
|
||||||
|
const scale = 110 / base.width;
|
||||||
|
const viewport = pdfPage.getViewport({ scale });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
await pdfPage.render({
|
||||||
|
canvasContext: canvas.getContext("2d")!,
|
||||||
|
viewport,
|
||||||
|
}).promise;
|
||||||
|
thumbCache.set(page, canvas);
|
||||||
|
container.appendChild(canvas);
|
||||||
|
} else {
|
||||||
|
// Comic: section.load() yields a page-document blob URL; extract
|
||||||
|
// the embedded image URL, draw it small, then free the full-size
|
||||||
|
// blob (unload) so thumbnailing doesn't hoard page images.
|
||||||
|
const section = rawBook?.sections?.[page];
|
||||||
|
const url = await section?.load?.();
|
||||||
|
if (!url) return;
|
||||||
|
const html = await (await fetch(url)).text();
|
||||||
|
const m = html.match(/src="(blob:[^"]+)"/);
|
||||||
|
if (!m) return;
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((res, rej) => {
|
||||||
|
img.onload = () => res();
|
||||||
|
img.onerror = () => rej(new Error("img load"));
|
||||||
|
img.src = m[1];
|
||||||
|
});
|
||||||
|
const scale = 110 / (img.naturalWidth || 1);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 110;
|
||||||
|
canvas.height = Math.round((img.naturalHeight || 150) * scale);
|
||||||
|
canvas.getContext("2d")!.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
thumbCache.set(page, canvas);
|
||||||
|
container.appendChild(canvas);
|
||||||
|
// Free the full-size page blob unless it's the page on screen
|
||||||
|
// (revoking a URL an iframe already loaded is harmless, but the
|
||||||
|
// current page may be re-requested on re-render).
|
||||||
|
if (this.renderer?.index !== page) section.unload?.();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
delete container.dataset.done;
|
||||||
|
console.warn("thumbnail render failed for page", page + 1, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goToPage(index: number) {
|
||||||
|
if (!this.view || typeof index !== "number" || index < 0) return;
|
||||||
|
this.pushBackStack();
|
||||||
|
this.view.goTo(index);
|
||||||
|
this.tocOpen = false;
|
||||||
|
},
|
||||||
goToBookmark(item: { cfi: string; page: number | null }) {
|
goToBookmark(item: { cfi: string; page: number | null }) {
|
||||||
if (!this.view) return;
|
if (!this.view) return;
|
||||||
if (item.cfi) {
|
if (item.cfi) {
|
||||||
|
|||||||
@@ -1029,6 +1029,51 @@
|
|||||||
background-color: color-mix(in srgb, currentColor 6%, transparent);
|
background-color: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page thumbnails grid (contents drawer, fixed-layout) */
|
||||||
|
.reader-thumb-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.reader-thumb {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
padding: 0;
|
||||||
|
background-color: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
}
|
||||||
|
.reader-thumb:hover {
|
||||||
|
border-color: color-mix(in srgb, currentColor 30%, transparent);
|
||||||
|
}
|
||||||
|
.reader-thumb.active {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
.reader-thumb-img {
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.reader-thumb-img canvas,
|
||||||
|
.reader-thumb-img img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.reader-thumb-num {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.2rem;
|
||||||
|
right: 0.35rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
/* In-book search */
|
/* In-book search */
|
||||||
.reader-search-bar {
|
.reader-search-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||||
Reference in new issue
Block a user