Release v0.1.4
添加原图查看器、相邻预取与桌面/移动端交互
This commit is contained in:
1 parent
e646d6762a
commit
deb12eb43f
12 files changed
+2144
-203
No files matched your search
Generated
+1
-1
@@ -618,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pixhelf"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pixhelf"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图
|
||||
|
||||
首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。
|
||||
|
||||
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。
|
||||
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。点击图片后,查看器会先显示这份缩略图,再加载原图,并提前预取相邻原图;超大原图会在浏览器内解码到 GPU 安全画布,不会生成第二套预览缓存。查看器支持键盘切换、滚轮缩放,以及移动端滑动、双击和双指缩放。缓存目录必须位于图库目录之外。
|
||||
|
||||
## 构建
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"dependencies": {
|
||||
"preact": "^10.27.1"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -63,7 +63,9 @@ try {
|
||||
cards: document.querySelectorAll("[data-image-id]").length,
|
||||
cardTags: [...document.querySelectorAll("[data-image-id]")]
|
||||
.map((element) => element.tagName),
|
||||
masonryColumns: document.querySelectorAll(".masonry-column").length,
|
||||
masonryColumns: Number(
|
||||
document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0,
|
||||
),
|
||||
loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length,
|
||||
brokenVisibleImages: [...document.images].filter((image) => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
@@ -113,9 +115,240 @@ try {
|
||||
};
|
||||
});
|
||||
|
||||
let resizeStability = null;
|
||||
if (target.name === "desktop") {
|
||||
await page.evaluate(() => {
|
||||
globalThis.__pixhelfResizeLoadedIds = [
|
||||
...document.querySelectorAll('[data-image-id][data-loaded="true"]'),
|
||||
].map((card) => card.getAttribute("data-image-id"));
|
||||
for (const card of document.querySelectorAll("[data-image-id]")) {
|
||||
card.setAttribute(
|
||||
"data-resize-probe",
|
||||
card.getAttribute("data-image-id") ?? "",
|
||||
);
|
||||
}
|
||||
globalThis.__pixhelfResizeSkeletonSeen = false;
|
||||
globalThis.__pixhelfResizeObserver = new MutationObserver(() => {
|
||||
if (document.querySelector(".skeleton-grid")) {
|
||||
globalThis.__pixhelfResizeSkeletonSeen = true;
|
||||
}
|
||||
});
|
||||
globalThis.__pixhelfResizeObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
globalThis.__pixhelfImageRequestsBeforeResize = performance
|
||||
.getEntriesByType("resource")
|
||||
.filter((entry) => new URL(entry.name).pathname === "/api/images")
|
||||
.length;
|
||||
});
|
||||
|
||||
for (const size of [
|
||||
{ width: 680, columns: 3 },
|
||||
{ width: 520, columns: 2 },
|
||||
{ width: 1000, columns: 3 },
|
||||
{ width: target.width, columns: 5 },
|
||||
]) {
|
||||
await page.setViewportSize({ width: size.width, height: target.height });
|
||||
await page.waitForFunction((columns) =>
|
||||
document.querySelector(".masonry")?.getAttribute("data-columns") === String(columns)
|
||||
, size.columns);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const loadedIds = globalThis.__pixhelfResizeLoadedIds ?? [];
|
||||
return loadedIds.every((id) => {
|
||||
const card = document.querySelector(`[data-image-id="${CSS.escape(id)}"]`);
|
||||
const image = card?.querySelector("img");
|
||||
return card?.getAttribute("data-loaded") === "true"
|
||||
&& image?.complete
|
||||
&& image.naturalWidth > 0;
|
||||
});
|
||||
});
|
||||
resizeStability = await page.evaluate(() => {
|
||||
globalThis.__pixhelfResizeObserver?.disconnect();
|
||||
const cards = [...document.querySelectorAll("[data-image-id]")];
|
||||
const imageRequestsAfter = performance
|
||||
.getEntriesByType("resource")
|
||||
.filter((entry) => new URL(entry.name).pathname === "/api/images")
|
||||
.length;
|
||||
const visibleImages = cards
|
||||
.map((card) => card.querySelector("img"))
|
||||
.filter((image) => {
|
||||
if (!image) return false;
|
||||
const rect = image.getBoundingClientRect();
|
||||
return rect.bottom > 0 && rect.top < innerHeight;
|
||||
});
|
||||
return {
|
||||
cardsPreserved: cards.every((card) =>
|
||||
card.getAttribute("data-resize-probe") === card.getAttribute("data-image-id")
|
||||
),
|
||||
loadedCardsPreserved: (globalThis.__pixhelfResizeLoadedIds ?? []).every((id) =>
|
||||
document.querySelector(`[data-image-id="${CSS.escape(id)}"]`)
|
||||
?.getAttribute("data-loaded") === "true"
|
||||
),
|
||||
skeletonSeen: globalThis.__pixhelfResizeSkeletonSeen,
|
||||
imagePageRequests: imageRequestsAfter
|
||||
- globalThis.__pixhelfImageRequestsBeforeResize,
|
||||
brokenVisibleImages: visibleImages.filter((image) =>
|
||||
!image.complete || image.naturalWidth <= 0
|
||||
).length,
|
||||
columns: Number(
|
||||
document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const firstViewerTitle = await page.locator("[data-image-id]").first().getAttribute("title");
|
||||
await page.locator("[data-image-id]").first().click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.waitForSelector(".image-viewer");
|
||||
const viewerChrome = await page.evaluate(() => ({
|
||||
fullscreenButtons: document.querySelectorAll(".viewer-fullscreen").length,
|
||||
gestureHelp: document.querySelectorAll(".viewer-gesture-help").length,
|
||||
loadingStatus: document.querySelectorAll(".viewer-load-status").length,
|
||||
loadingCopyVisible: document.body.textContent?.includes("正在加载原图") ?? false,
|
||||
}));
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-full-loaded") === "true"
|
||||
, undefined, { timeout: 60_000 });
|
||||
await page.waitForFunction(() => {
|
||||
const originalPaths = performance.getEntriesByType("resource")
|
||||
.map((entry) => new URL(entry.name).pathname)
|
||||
.filter((path) => path.endsWith("/original"));
|
||||
return new Set(originalPaths).size >= 2;
|
||||
}, undefined, { timeout: 60_000 });
|
||||
await page.waitForTimeout(260);
|
||||
const dialogsAfterImageClick = await page.locator('[role="dialog"]').count();
|
||||
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` });
|
||||
const viewer = await page.evaluate(() => {
|
||||
const overlay = document.querySelector(".image-viewer");
|
||||
const original = document.querySelector(".viewer-original");
|
||||
const media = document.querySelector(".viewer-media")?.getBoundingClientRect();
|
||||
const resourcePaths = performance.getEntriesByType("resource")
|
||||
.map((entry) => new URL(entry.name).pathname);
|
||||
const originalSource = original instanceof HTMLImageElement
|
||||
? original.currentSrc || original.src
|
||||
: original?.getAttribute("data-original-url") ?? "";
|
||||
return {
|
||||
title: document.querySelector(".viewer-heading strong")?.textContent ?? "",
|
||||
fullLoaded: overlay?.getAttribute("data-full-loaded"),
|
||||
originalPath: originalSource ? new URL(originalSource, location.href).pathname : "",
|
||||
renderer: overlay?.getAttribute("data-renderer"),
|
||||
renderedPixels: original instanceof HTMLCanvasElement
|
||||
? [original.width, original.height]
|
||||
: [original?.clientWidth ?? 0, original?.clientHeight ?? 0],
|
||||
prefetchedOriginals: new Set(
|
||||
resourcePaths.filter((path) => path.endsWith("/original")),
|
||||
).size,
|
||||
previewRequests: resourcePaths.filter((path) => path.endsWith("/preview")).length,
|
||||
rootInert: document.querySelector("#root")?.inert ?? false,
|
||||
closeFocused: document.activeElement?.classList.contains("viewer-close") ?? false,
|
||||
mediaContained: media
|
||||
? media.left >= -1 && media.top >= -1 && media.right <= innerWidth + 1 &&
|
||||
media.bottom <= innerHeight + 1
|
||||
: false,
|
||||
position: document.querySelector(".viewer-heading span")?.textContent ?? "",
|
||||
};
|
||||
});
|
||||
Object.assign(viewer, viewerChrome);
|
||||
await page.locator(".viewer-fullscreen").click();
|
||||
await page.waitForFunction(() =>
|
||||
document.fullscreenElement?.classList.contains("image-viewer")
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".viewer-fullscreen")?.getAttribute("aria-label") === "退出全屏"
|
||||
);
|
||||
viewer.fullscreenEntered = true;
|
||||
viewer.fullscreenLabel = await page.locator(".viewer-fullscreen").getAttribute("aria-label");
|
||||
await page.locator(".viewer-fullscreen").click();
|
||||
await page.waitForFunction(() => document.fullscreenElement === null);
|
||||
viewer.fullscreenExited = true;
|
||||
await page.locator(".viewer-zoom-controls .viewer-control").last().click();
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "true"
|
||||
);
|
||||
viewer.zoomed = await page.locator(".viewer-zoom-value").textContent();
|
||||
await page.waitForTimeout(260);
|
||||
viewer.zoomScale = await page.locator(".viewer-media").evaluate((media) =>
|
||||
new DOMMatrix(getComputedStyle(media).transform).a
|
||||
);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.waitForFunction((title) =>
|
||||
document.querySelector(".viewer-heading strong")?.textContent !== title
|
||||
, firstViewerTitle);
|
||||
viewer.keyboardNavigation = await page.locator(".viewer-heading strong").textContent();
|
||||
if (target.name === "mobile") {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const dispatchTouch = (type, touchPoints) => cdp.send("Input.dispatchTouchEvent", {
|
||||
type,
|
||||
touchPoints: touchPoints.map((point) => ({
|
||||
...point,
|
||||
radiusX: 2,
|
||||
radiusY: 2,
|
||||
force: 1,
|
||||
})),
|
||||
});
|
||||
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false"
|
||||
);
|
||||
await dispatchTouch("touchStart", [
|
||||
{ id: 11, x: 145, y: 420 },
|
||||
{ id: 12, x: 245, y: 420 },
|
||||
]);
|
||||
await page.waitForTimeout(32);
|
||||
await dispatchTouch("touchMove", [
|
||||
{ id: 11, x: 95, y: 420 },
|
||||
{ id: 12, x: 295, y: 420 },
|
||||
]);
|
||||
await page.waitForTimeout(32);
|
||||
await dispatchTouch("touchEnd", []);
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "true"
|
||||
);
|
||||
viewer.pinchZoomed = true;
|
||||
await page.waitForTimeout(40);
|
||||
viewer.pinchScale = await page.locator(".viewer-media").evaluate((media) =>
|
||||
new DOMMatrix(getComputedStyle(media).transform).a
|
||||
);
|
||||
|
||||
await page.touchscreen.tap(195, 420);
|
||||
await page.waitForTimeout(70);
|
||||
await page.touchscreen.tap(195, 420);
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false"
|
||||
);
|
||||
viewer.doubleTapReset = true;
|
||||
await page.waitForTimeout(260);
|
||||
viewer.doubleTapScale = await page.locator(".viewer-media").evaluate((media) =>
|
||||
new DOMMatrix(getComputedStyle(media).transform).a
|
||||
);
|
||||
|
||||
const titleBeforeSwipe = await page.locator(".viewer-heading strong").textContent();
|
||||
await dispatchTouch("touchStart", [{ id: 31, x: 320, y: 420 }]);
|
||||
await page.waitForTimeout(32);
|
||||
await dispatchTouch("touchMove", [{ id: 31, x: 90, y: 420 }]);
|
||||
await page.waitForTimeout(32);
|
||||
await dispatchTouch("touchEnd", []);
|
||||
await page.waitForFunction((title) =>
|
||||
document.querySelector(".viewer-heading strong")?.textContent !== title
|
||||
, titleBeforeSwipe);
|
||||
viewer.swipeNavigation = await page.locator(".viewer-heading strong").textContent();
|
||||
|
||||
await dispatchTouch("touchStart", [{ id: 41, x: 195, y: 210 }]);
|
||||
await page.waitForTimeout(32);
|
||||
await dispatchTouch("touchMove", [{ id: 41, x: 195, y: 510 }]);
|
||||
await page.waitForTimeout(32);
|
||||
await dispatchTouch("touchEnd", []);
|
||||
viewer.closedByGesture = true;
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await page.waitForSelector(".image-viewer", { state: "detached" });
|
||||
viewer.closed = await page.locator(".image-viewer").count();
|
||||
viewer.rootRestored = await page.locator("#root").evaluate((root) => !root.inert);
|
||||
|
||||
const searchToggleBefore = await page.locator(".search-toggle").boundingBox();
|
||||
await page.locator(".search-toggle").click();
|
||||
@@ -314,6 +547,10 @@ try {
|
||||
);
|
||||
}
|
||||
const clickCount = await page.evaluate(() => globalThis.__pixhelfCardClickCount ?? 0);
|
||||
await page.waitForSelector(".image-viewer");
|
||||
const viewerOpenedFromTouch = await page.locator(".image-viewer").count();
|
||||
await page.locator(".viewer-close").click();
|
||||
await page.waitForSelector(".image-viewer", { state: "detached" });
|
||||
mobileCardNames = {
|
||||
shownOnContact,
|
||||
retainedOnRelease,
|
||||
@@ -323,6 +560,7 @@ try {
|
||||
activeCount,
|
||||
displayedCount,
|
||||
clickCount,
|
||||
viewerOpenedFromTouch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,7 +599,9 @@ try {
|
||||
).visibility,
|
||||
ariaHidden: document.querySelector(".desktop-sidebar")?.getAttribute("aria-hidden"),
|
||||
stored: localStorage.getItem("pixhelf.sidebar-collapsed"),
|
||||
columns: document.querySelectorAll(".masonry-column").length,
|
||||
columns: Number(
|
||||
document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0,
|
||||
),
|
||||
redundantHeadings: document.querySelectorAll(".sidebar-heading").length,
|
||||
pathDetails: document.querySelectorAll(".album-copy small").length,
|
||||
progressPanels: document.querySelectorAll(".sidebar-progress").length,
|
||||
@@ -505,7 +745,9 @@ try {
|
||||
const result = {
|
||||
name: target.name,
|
||||
...layout,
|
||||
resizeStability,
|
||||
dialogsAfterImageClick,
|
||||
viewer,
|
||||
searchDisclosure,
|
||||
exploration,
|
||||
homeNavigation,
|
||||
@@ -524,7 +766,40 @@ try {
|
||||
layout.masonryColumns !== expectedColumns ||
|
||||
layout.cardTags.some((tag) => tag !== "FIGURE") ||
|
||||
layout.dialogs !== 0 ||
|
||||
dialogsAfterImageClick !== 0 ||
|
||||
dialogsAfterImageClick !== 1 ||
|
||||
viewer.title !== firstViewerTitle ||
|
||||
viewer.fullLoaded !== "true" ||
|
||||
!viewer.originalPath.endsWith("/original") ||
|
||||
viewer.renderer !== "bitmap" ||
|
||||
viewer.renderedPixels.some((size) => size <= 0 || size > 4096) ||
|
||||
viewer.prefetchedOriginals < 2 ||
|
||||
viewer.previewRequests !== 0 ||
|
||||
viewer.fullscreenButtons !== 1 ||
|
||||
viewer.gestureHelp !== 0 ||
|
||||
viewer.loadingStatus !== 0 ||
|
||||
viewer.loadingCopyVisible ||
|
||||
!viewer.fullscreenEntered ||
|
||||
viewer.fullscreenLabel !== "退出全屏" ||
|
||||
!viewer.fullscreenExited ||
|
||||
!viewer.rootInert ||
|
||||
!viewer.closeFocused ||
|
||||
!viewer.mediaContained ||
|
||||
!viewer.position.includes("/") ||
|
||||
viewer.zoomed !== "150%" ||
|
||||
Math.abs(viewer.zoomScale - 1.5) > 0.02 ||
|
||||
!viewer.keyboardNavigation ||
|
||||
viewer.keyboardNavigation === firstViewerTitle ||
|
||||
(target.name === "mobile" && (
|
||||
!viewer.pinchZoomed ||
|
||||
viewer.pinchScale < 1.5 ||
|
||||
!viewer.doubleTapReset ||
|
||||
Math.abs(viewer.doubleTapScale - 1) > 0.02 ||
|
||||
!viewer.swipeNavigation ||
|
||||
viewer.swipeNavigation === viewer.keyboardNavigation ||
|
||||
!viewer.closedByGesture
|
||||
)) ||
|
||||
viewer.closed !== 0 ||
|
||||
!viewer.rootRestored ||
|
||||
searchDisclosure.expanded !== "true" ||
|
||||
!searchDisclosure.focused ||
|
||||
searchDisclosure.panelRole !== "group" ||
|
||||
@@ -563,6 +838,15 @@ try {
|
||||
layout.exploreSearchGap > 8 ||
|
||||
layout.sidebarStatuses !== 1 ||
|
||||
layout.sidebarGalleryMetas !== 1 ||
|
||||
(target.name === "desktop" && (
|
||||
!resizeStability ||
|
||||
!resizeStability.cardsPreserved ||
|
||||
!resizeStability.loadedCardsPreserved ||
|
||||
resizeStability.skeletonSeen ||
|
||||
resizeStability.imagePageRequests !== 0 ||
|
||||
resizeStability.brokenVisibleImages !== 0 ||
|
||||
resizeStability.columns !== 5
|
||||
)) ||
|
||||
!layout.galleryPath.startsWith("/") ||
|
||||
!layout.galleryCount.endsWith("张图片") ||
|
||||
layout.galleryMetaRowDelta < 0 ||
|
||||
@@ -643,6 +927,7 @@ try {
|
||||
mobileCardNames.activeCount !== 1 ||
|
||||
mobileCardNames.displayedCount !== 1 ||
|
||||
mobileCardNames.clickCount !== 1 ||
|
||||
mobileCardNames.viewerOpenedFromTouch !== 1 ||
|
||||
layout.cardNameDisplay === "none" ||
|
||||
layout.cardNameOpacity > 0.01 ||
|
||||
layout.cardNameWhiteSpace !== "nowrap"
|
||||
|
||||
+263
-175
@@ -22,6 +22,7 @@ import {
|
||||
useState,
|
||||
} from "preact/hooks";
|
||||
import { ToolbarPopover } from "./ToolbarPopover";
|
||||
import { ImageViewer, preloadOriginalImage } from "./ImageViewer";
|
||||
import { getGallery, getImages, getStatus, takeInitialBootstrap } from "./api";
|
||||
import type {
|
||||
GalleryImage,
|
||||
@@ -30,7 +31,6 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
const MOBILE_PAGE_SIZE = 48;
|
||||
const CARD_PREFETCH_MARGIN = "1200px 0px";
|
||||
const MOBILE_PAGE_PREFETCH_MARGIN = "1400px 0px";
|
||||
const DESKTOP_PAGE_PREFETCH_MARGIN = "900px 0px";
|
||||
@@ -168,42 +168,31 @@ function createExploreSeed(): string {
|
||||
}
|
||||
|
||||
const CARD_LOAD_CALLBACKS = new WeakMap<Element, () => void>();
|
||||
const READY_THUMBNAIL_IDS = new Set<string>();
|
||||
let cardLoadObserver: IntersectionObserver | null = null;
|
||||
|
||||
function observeCardLoad(element: Element, load: () => void): () => void {
|
||||
let observing = false;
|
||||
const startObserving = () => {
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
load();
|
||||
return;
|
||||
}
|
||||
if (!cardLoadObserver) {
|
||||
cardLoadObserver = new IntersectionObserver(
|
||||
(entries, observer) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const callback = CARD_LOAD_CALLBACKS.get(entry.target);
|
||||
CARD_LOAD_CALLBACKS.delete(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
callback?.();
|
||||
}
|
||||
},
|
||||
{ rootMargin: CARD_PREFETCH_MARGIN },
|
||||
);
|
||||
}
|
||||
observing = true;
|
||||
CARD_LOAD_CALLBACKS.set(element, load);
|
||||
cardLoadObserver.observe(element);
|
||||
};
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
startObserving();
|
||||
} else {
|
||||
window.addEventListener("load", startObserving, { once: true });
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
load();
|
||||
return () => undefined;
|
||||
}
|
||||
if (!cardLoadObserver) {
|
||||
cardLoadObserver = new IntersectionObserver(
|
||||
(entries, observer) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const callback = CARD_LOAD_CALLBACKS.get(entry.target);
|
||||
CARD_LOAD_CALLBACKS.delete(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
callback?.();
|
||||
}
|
||||
},
|
||||
{ rootMargin: CARD_PREFETCH_MARGIN },
|
||||
);
|
||||
}
|
||||
CARD_LOAD_CALLBACKS.set(element, load);
|
||||
cardLoadObserver.observe(element);
|
||||
return () => {
|
||||
window.removeEventListener("load", startObserving);
|
||||
if (!observing) return;
|
||||
CARD_LOAD_CALLBACKS.delete(element);
|
||||
cardLoadObserver?.unobserve(element);
|
||||
};
|
||||
@@ -234,10 +223,10 @@ function App() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(initialSidebarCollapsed);
|
||||
const [viewerImageId, setViewerImageId] = useState<string | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const compactLayout = useMediaQuery("(max-width: 720px)");
|
||||
const mobileNavMounted = useDelayedUnmount(mobileNavOpen, MOBILE_NAV_EXIT_MS);
|
||||
const pageSize = compactLayout ? MOBILE_PAGE_SIZE : PAGE_SIZE;
|
||||
const debouncedSearch = useDebounced(search.trim(), 250);
|
||||
const requestVersionRef = useRef(0);
|
||||
const summaryRevisionRef = useRef<string | null>(
|
||||
@@ -245,7 +234,7 @@ function App() {
|
||||
);
|
||||
const skipInitialImagesRef = useRef(Boolean(INITIAL_BOOTSTRAP));
|
||||
const loadMoreControllerRef = useRef<AbortController | null>(null);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMorePromiseRef = useRef<Promise<GalleryImage[]> | null>(null);
|
||||
const { images, total, nextOffset } = imagePage;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -361,7 +350,7 @@ function App() {
|
||||
const requestVersion = ++requestVersionRef.current;
|
||||
loadMoreControllerRef.current?.abort();
|
||||
loadMoreControllerRef.current = null;
|
||||
loadingMoreRef.current = false;
|
||||
loadMorePromiseRef.current = null;
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
setError(null);
|
||||
@@ -373,7 +362,7 @@ function App() {
|
||||
sort: exploreSeed ? "explore" : "name-asc",
|
||||
seed: exploreSeed,
|
||||
offset: 0,
|
||||
limit: pageSize,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
@@ -399,48 +388,57 @@ function App() {
|
||||
controller.abort();
|
||||
const loadMoreController = loadMoreControllerRef.current;
|
||||
loadMoreControllerRef.current = null;
|
||||
loadingMoreRef.current = false;
|
||||
loadMorePromiseRef.current = null;
|
||||
loadMoreController?.abort();
|
||||
};
|
||||
}, [album, debouncedSearch, exploreSeed, pageSize, reloadToken]);
|
||||
}, [album, debouncedSearch, exploreSeed, reloadToken]);
|
||||
|
||||
const loadMore = useCallback((): Promise<GalleryImage[]> => {
|
||||
if (loading || nextOffset === null) return Promise.resolve([]);
|
||||
const pending = loadMorePromiseRef.current;
|
||||
if (pending) return pending;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loading || nextOffset === null || loadingMoreRef.current) return;
|
||||
const controller = new AbortController();
|
||||
const requestVersion = requestVersionRef.current;
|
||||
loadMoreControllerRef.current = controller;
|
||||
loadingMoreRef.current = true;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await getImages(
|
||||
{
|
||||
album,
|
||||
search: debouncedSearch,
|
||||
sort: exploreSeed ? "explore" : "name-asc",
|
||||
seed: exploreSeed,
|
||||
offset: nextOffset,
|
||||
limit: pageSize,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (requestVersion !== requestVersionRef.current) return;
|
||||
setImagePage((current) => ({
|
||||
images: appendUniqueImages(current.images, page.items),
|
||||
total: page.total,
|
||||
nextOffset: page.nextOffset,
|
||||
}));
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted && requestVersion === requestVersionRef.current) {
|
||||
setError(errorMessage(reason, "无法继续加载图片"));
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const page = await getImages(
|
||||
{
|
||||
album,
|
||||
search: debouncedSearch,
|
||||
sort: exploreSeed ? "explore" : "name-asc",
|
||||
seed: exploreSeed,
|
||||
offset: nextOffset,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (requestVersion !== requestVersionRef.current) return [];
|
||||
setImagePage((current) => ({
|
||||
images: appendUniqueImages(current.images, page.items),
|
||||
total: page.total,
|
||||
nextOffset: page.nextOffset,
|
||||
}));
|
||||
return page.items;
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted && requestVersion === requestVersionRef.current) {
|
||||
setError(errorMessage(reason, "无法继续加载图片"));
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
if (loadMoreControllerRef.current === controller) {
|
||||
loadMoreControllerRef.current = null;
|
||||
loadMorePromiseRef.current = null;
|
||||
if (requestVersion === requestVersionRef.current) setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (loadMoreControllerRef.current === controller) {
|
||||
loadMoreControllerRef.current = null;
|
||||
loadingMoreRef.current = false;
|
||||
if (requestVersion === requestVersionRef.current) setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [album, debouncedSearch, exploreSeed, loading, nextOffset, pageSize]);
|
||||
})();
|
||||
loadMorePromiseRef.current = promise;
|
||||
return promise;
|
||||
}, [album, debouncedSearch, exploreSeed, loading, nextOffset]);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -460,18 +458,49 @@ function App() {
|
||||
return () => observer.disconnect();
|
||||
}, [compactLayout, loadMore, nextOffset]);
|
||||
|
||||
const viewerIndex = viewerImageId
|
||||
? images.findIndex((image) => image.id === viewerImageId)
|
||||
: -1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerImageId || viewerIndex < 0 || nextOffset === null) return;
|
||||
if (viewerIndex >= images.length - 5) void loadMore();
|
||||
}, [images.length, loadMore, nextOffset, viewerImageId, viewerIndex]);
|
||||
|
||||
const navigateViewer = useCallback((direction: -1 | 1) => {
|
||||
if (!viewerImageId) return;
|
||||
const currentIndex = images.findIndex((image) => image.id === viewerImageId);
|
||||
if (currentIndex < 0) return;
|
||||
const target = images[currentIndex + direction];
|
||||
if (target) {
|
||||
setViewerImageId(target.id);
|
||||
return;
|
||||
}
|
||||
if (direction < 0 || nextOffset === null) return;
|
||||
|
||||
const startingId = viewerImageId;
|
||||
void loadMore().then((incoming) => {
|
||||
const next = incoming[0];
|
||||
if (!next) return;
|
||||
setViewerImageId((current) => current === startingId ? next.id : current);
|
||||
});
|
||||
}, [images, loadMore, nextOffset, viewerImageId]);
|
||||
|
||||
const galleryPath = `/${album.replace(/^\/+/, "")}`;
|
||||
const chooseAlbum = (path: string) => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
setAlbum(path);
|
||||
setExploreSeed("");
|
||||
setViewerImageId(null);
|
||||
setMobileNavOpen(false);
|
||||
};
|
||||
const changeSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
setExploreSeed("");
|
||||
setViewerImageId(null);
|
||||
};
|
||||
const startExploring = () => {
|
||||
setViewerImageId(null);
|
||||
setExploreSeed(createExploreSeed());
|
||||
};
|
||||
const goHome = () => {
|
||||
@@ -479,6 +508,7 @@ function App() {
|
||||
setAlbum("");
|
||||
setSearch("");
|
||||
setExploreSeed("");
|
||||
setViewerImageId(null);
|
||||
setMobileNavOpen(false);
|
||||
};
|
||||
return (
|
||||
@@ -552,6 +582,7 @@ function App() {
|
||||
<MasonryGallery
|
||||
images={images}
|
||||
initialColumnCount={compactLayout ? 2 : 5}
|
||||
onOpen={setViewerImageId}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
@@ -570,6 +601,17 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{viewerIndex >= 0 && (
|
||||
<ImageViewer
|
||||
images={images}
|
||||
activeIndex={viewerIndex}
|
||||
total={total}
|
||||
hasMore={nextOffset !== null}
|
||||
loadingMore={loadingMore}
|
||||
onNavigate={navigateViewer}
|
||||
onClose={() => setViewerImageId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -889,114 +931,126 @@ function AlbumButton({
|
||||
);
|
||||
}
|
||||
|
||||
function useColumnCount(
|
||||
type MasonryMetrics = {
|
||||
width: number;
|
||||
columnCount: number;
|
||||
gap: number;
|
||||
};
|
||||
|
||||
function useMasonryMetrics(
|
||||
ref: RefObject<HTMLDivElement | null>,
|
||||
initialColumnCount: number,
|
||||
): number {
|
||||
const [columns, setColumns] = useState(initialColumnCount);
|
||||
): MasonryMetrics {
|
||||
const [metrics, setMetrics] = useState<MasonryMetrics>({
|
||||
width: 0,
|
||||
columnCount: initialColumnCount,
|
||||
gap: 6,
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
let frame = 0;
|
||||
let pendingWidth = element.clientWidth;
|
||||
const update = (width: number) => {
|
||||
if (width <= 0) return;
|
||||
const minimumCardWidth = 218;
|
||||
const gap = 6;
|
||||
const declaredGap = Number.parseFloat(
|
||||
getComputedStyle(element).getPropertyValue("--masonry-gap"),
|
||||
);
|
||||
const gap = Number.isFinite(declaredGap) ? declaredGap : 6;
|
||||
const next = Math.floor((width + gap) / (minimumCardWidth + gap));
|
||||
setColumns(Math.min(6, Math.max(2, next)));
|
||||
const roundedWidth = Math.round(width * 100) / 100;
|
||||
const columnCount = Math.min(6, Math.max(2, next));
|
||||
setMetrics((current) => (
|
||||
current.width === roundedWidth
|
||||
&& current.columnCount === columnCount
|
||||
&& current.gap === gap
|
||||
? current
|
||||
: { width: roundedWidth, columnCount, gap }
|
||||
));
|
||||
};
|
||||
update(element.clientWidth);
|
||||
const observer = new ResizeObserver(([entry]) => update(entry.contentRect.width));
|
||||
const schedule = (width: number) => {
|
||||
pendingWidth = width;
|
||||
if (frame) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
update(pendingWidth);
|
||||
});
|
||||
};
|
||||
|
||||
update(pendingWidth);
|
||||
const observer = new ResizeObserver(([entry]) => schedule(entry.contentRect.width));
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [initialColumnCount, ref]);
|
||||
return columns;
|
||||
return () => {
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function MasonryGallery({
|
||||
images,
|
||||
initialColumnCount,
|
||||
onOpen,
|
||||
}: {
|
||||
images: GalleryImage[];
|
||||
initialColumnCount: number;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [activeNameId, setActiveNameId] = useState<string | null>(null);
|
||||
const [readyRevealRows, setReadyRevealRows] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const settledRevealIdsRef = useRef(new Set<string>());
|
||||
const revealRowMembersRef = useRef<string[][]>([]);
|
||||
const columnCount = useColumnCount(ref, initialColumnCount);
|
||||
const eagerRowCount = 1;
|
||||
const { width, columnCount, gap } = useMasonryMetrics(ref, initialColumnCount);
|
||||
|
||||
const showName = useCallback((id: string) => setActiveNameId(id), []);
|
||||
|
||||
const markRevealImageSettled = useCallback((rowIndex: number, id: string) => {
|
||||
settledRevealIdsRef.current.add(id);
|
||||
const rowMembers = revealRowMembersRef.current[rowIndex];
|
||||
if (!rowMembers?.every((memberId) => settledRevealIdsRef.current.has(memberId))) return;
|
||||
|
||||
setReadyRevealRows((current) => {
|
||||
if (current.has(rowIndex)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(rowIndex);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const result: { image: GalleryImage; index: number }[][] = Array.from(
|
||||
{ length: columnCount },
|
||||
() => [],
|
||||
);
|
||||
const layout = useMemo(() => {
|
||||
if (width <= 0) return { height: 0, items: [] };
|
||||
const cardWidth = Math.max(1, (width - gap * (columnCount - 1)) / columnCount);
|
||||
const heights = Array(columnCount).fill(0) as number[];
|
||||
images.forEach((image, index) => {
|
||||
const items = images.map((image, index) => {
|
||||
const target = heights.indexOf(Math.min(...heights));
|
||||
result[target].push({ image, index });
|
||||
heights[target] += image.height / Math.max(image.width, 1) + 0.05;
|
||||
const cardHeight = Math.max(
|
||||
64,
|
||||
cardWidth * image.height / Math.max(image.width, 1),
|
||||
);
|
||||
const top = heights[target];
|
||||
heights[target] = top + cardHeight + gap;
|
||||
return {
|
||||
image,
|
||||
index,
|
||||
style: {
|
||||
left: target * (cardWidth + gap),
|
||||
top,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
} as CSSProperties,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
}, [columnCount, images]);
|
||||
|
||||
// Masonry row indices drift apart vertically, so only coordinate the first row.
|
||||
const synchronizedRowCount = columnCount <= 2 && images.length > 0 ? 1 : 0;
|
||||
const synchronizedRowMembers = useMemo(
|
||||
() => Array.from({ length: synchronizedRowCount }, (_, rowIndex) => (
|
||||
columns
|
||||
.map((column) => column[rowIndex]?.image.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)),
|
||||
[columns, synchronizedRowCount],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
revealRowMembersRef.current = synchronizedRowMembers;
|
||||
}, [synchronizedRowMembers]);
|
||||
return {
|
||||
items,
|
||||
height: items.length ? Math.max(...heights) - gap : 0,
|
||||
};
|
||||
}, [columnCount, gap, images, width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="masonry"
|
||||
style={{ "--columns": columnCount } as CSSProperties}
|
||||
data-columns={columnCount}
|
||||
style={{ height: layout.height } as CSSProperties}
|
||||
>
|
||||
{columns.map((column, columnIndex) => (
|
||||
<div className="masonry-column" key={columnIndex}>
|
||||
{column.map(({ image, index }, rowIndex) => {
|
||||
const synchronizeReveal = rowIndex < synchronizedRowCount;
|
||||
return (
|
||||
<ImageCard
|
||||
key={image.id}
|
||||
image={image}
|
||||
eager={rowIndex < eagerRowCount}
|
||||
highPriority={index === 0}
|
||||
revealReady={!synchronizeReveal || readyRevealRows.has(rowIndex)}
|
||||
revealGroup={synchronizeReveal ? rowIndex : undefined}
|
||||
nameVisible={activeNameId === image.id}
|
||||
onNameTouch={showName}
|
||||
onSettled={synchronizeReveal ? markRevealImageSettled : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{layout.items.map(({ image, index, style }) => (
|
||||
<ImageCard
|
||||
key={image.id}
|
||||
image={image}
|
||||
layoutStyle={style}
|
||||
eager={index < columnCount}
|
||||
highPriority={index === 0}
|
||||
nameVisible={activeNameId === image.id}
|
||||
onNameTouch={showName}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1004,28 +1058,29 @@ function MasonryGallery({
|
||||
|
||||
const ImageCard = memo(function ImageCard({
|
||||
image,
|
||||
layoutStyle,
|
||||
eager,
|
||||
highPriority,
|
||||
revealReady,
|
||||
revealGroup,
|
||||
nameVisible,
|
||||
onNameTouch,
|
||||
onSettled,
|
||||
onOpen,
|
||||
}: {
|
||||
image: GalleryImage;
|
||||
layoutStyle: CSSProperties;
|
||||
eager: boolean;
|
||||
highPriority: boolean;
|
||||
revealReady: boolean;
|
||||
revealGroup?: number;
|
||||
nameVisible: boolean;
|
||||
onNameTouch: (id: string) => void;
|
||||
onSettled?: (rowIndex: number, id: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const alreadyReady = READY_THUMBNAIL_IDS.has(image.id);
|
||||
const [loaded, setLoaded] = useState(alreadyReady);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loadRequested, setLoadRequested] = useState(eager);
|
||||
const [loadRequested, setLoadRequested] = useState(eager || alreadyReady);
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const retryTimerRef = useRef(0);
|
||||
|
||||
useEffect(() => () => window.clearTimeout(retryTimerRef.current), []);
|
||||
@@ -1040,24 +1095,41 @@ const ImageCard = memo(function ImageCard({
|
||||
return observeCardLoad(card, () => setLoadRequested(true));
|
||||
}, [eager, loadRequested]);
|
||||
|
||||
const handleError = () => {
|
||||
setLoaded(false);
|
||||
setFailed(true);
|
||||
const delay = IMAGE_RETRY_DELAYS_MS[attempt];
|
||||
if (delay === undefined) {
|
||||
if (revealGroup !== undefined) onSettled?.(revealGroup, image.id);
|
||||
return;
|
||||
}
|
||||
retryTimerRef.current = window.setTimeout(() => {
|
||||
setAttempt((current) => current + 1);
|
||||
setFailed(false);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const thumbnailUrl = `/api/images/${encodeURIComponent(image.id)}/thumbnail`;
|
||||
const retryQuery = attempt ? `retry=${attempt}` : "";
|
||||
const imageUrl = retryQuery ? `${thumbnailUrl}?${retryQuery}` : thumbnailUrl;
|
||||
const revealed = loaded && revealReady;
|
||||
|
||||
const markLoaded = useCallback(() => {
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
READY_THUMBNAIL_IDS.add(image.id);
|
||||
setLoaded(true);
|
||||
setFailed(false);
|
||||
setRetrying(false);
|
||||
}, [image.id]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = imageRef.current;
|
||||
if (element?.complete && element.naturalWidth > 0) markLoaded();
|
||||
}, [imageUrl, loadRequested, markLoaded]);
|
||||
|
||||
const handleError = () => {
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
READY_THUMBNAIL_IDS.delete(image.id);
|
||||
setLoaded(false);
|
||||
const delay = IMAGE_RETRY_DELAYS_MS[attempt];
|
||||
if (delay === undefined) {
|
||||
setRetrying(false);
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
setFailed(false);
|
||||
setRetrying(true);
|
||||
retryTimerRef.current = window.setTimeout(() => {
|
||||
setAttempt((current) => current + 1);
|
||||
setRetrying(false);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
return (
|
||||
<figure
|
||||
ref={cardRef}
|
||||
@@ -1065,30 +1137,46 @@ const ImageCard = memo(function ImageCard({
|
||||
title={image.name}
|
||||
data-image-id={image.id}
|
||||
data-name-visible={nameVisible}
|
||||
data-loaded={revealed}
|
||||
data-loaded={loaded}
|
||||
data-failed={failed}
|
||||
data-reveal-ready={revealReady}
|
||||
data-loading={loadRequested && !loaded && !failed}
|
||||
data-retrying={retrying}
|
||||
data-eager={eager}
|
||||
data-high-priority={highPriority}
|
||||
style={{
|
||||
...layoutStyle,
|
||||
aspectRatio: `${image.width} / ${image.height}`,
|
||||
} as CSSProperties}
|
||||
onPointerEnter={() => preloadOriginalImage(image)}
|
||||
onPointerDown={(event) => {
|
||||
preloadOriginalImage(image);
|
||||
if (event.pointerType !== "mouse") onNameTouch(image.id);
|
||||
}}
|
||||
onFocus={() => preloadOriginalImage(image)}
|
||||
onClick={() => onOpen(image.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onOpen(image.id);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`查看 ${image.name}`}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{!failed && loadRequested ? (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={imageUrl}
|
||||
alt={image.name}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority={highPriority ? "high" : "auto"}
|
||||
className={revealed ? "loaded" : ""}
|
||||
onLoad={() => {
|
||||
setLoaded(true);
|
||||
if (revealGroup !== undefined) onSettled?.(revealGroup, image.id);
|
||||
}}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
draggable={false}
|
||||
className={loaded ? "loaded" : ""}
|
||||
onLoad={markLoaded}
|
||||
onError={handleError}
|
||||
/>
|
||||
) : failed ? (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,14 @@ export function Check(props: IconProps) {
|
||||
return <Icon {...props}><path d="M20 6 9 17l-5-5" /></Icon>;
|
||||
}
|
||||
|
||||
export function ChevronLeft(props: IconProps) {
|
||||
return <Icon {...props}><path d="m15 18-6-6 6-6" /></Icon>;
|
||||
}
|
||||
|
||||
export function ChevronRight(props: IconProps) {
|
||||
return <Icon {...props}><path d="m9 18 6-6-6-6" /></Icon>;
|
||||
}
|
||||
|
||||
export function Dices(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
@@ -47,6 +55,16 @@ export function Dices(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Download(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M12 15V3" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function Folder(props: IconProps) {
|
||||
return <Icon {...props}><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /></Icon>;
|
||||
}
|
||||
@@ -76,6 +94,28 @@ export function LoaderCircle(props: IconProps) {
|
||||
return <Icon {...props}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></Icon>;
|
||||
}
|
||||
|
||||
export function Maximize2(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="M15 3h6v6" /><path d="m21 3-7 7" />
|
||||
<path d="m3 21 7-7" /><path d="M9 21H3v-6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function Minimize2(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<path d="m14 10 7-7" /><path d="M20 10h-6V4" />
|
||||
<path d="m3 21 7-7" /><path d="M4 14h6v6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function Minus(props: IconProps) {
|
||||
return <Icon {...props}><path d="M5 12h14" /></Icon>;
|
||||
}
|
||||
|
||||
export function PanelLeftClose(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
@@ -94,6 +134,10 @@ export function PanelLeftOpen(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Plus(props: IconProps) {
|
||||
return <Icon {...props}><path d="M5 12h14" /><path d="M12 5v14" /></Icon>;
|
||||
}
|
||||
|
||||
export function RefreshCw(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
|
||||
+401
-17
@@ -62,7 +62,8 @@ button {
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
select:focus-visible,
|
||||
.image-card:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -742,22 +743,13 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.masonry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns), minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.masonry-column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
--masonry-gap: 6px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
min-height: 64px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
@@ -767,6 +759,8 @@ select:focus-visible {
|
||||
border: 1px solid rgba(26, 35, 31, 0.08);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 1px rgba(21, 31, 27, 0.05);
|
||||
cursor: zoom-in;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition:
|
||||
border-color var(--motion-medium) ease,
|
||||
box-shadow var(--motion-medium) var(--ease-standard),
|
||||
@@ -787,7 +781,7 @@ select:focus-visible {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.image-card[data-eager="true"]::before {
|
||||
.image-card[data-loading="true"]::before {
|
||||
animation: card-shimmer 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -936,6 +930,347 @@ select:focus-visible {
|
||||
|
||||
.load-sentinel > * { animation: feedback-in var(--motion-medium) ease-out both; }
|
||||
|
||||
.image-viewer {
|
||||
--viewer-dismiss-progress: 0;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
color: #f5f7f6;
|
||||
isolation: isolate;
|
||||
animation: viewer-enter 180ms ease-out both;
|
||||
}
|
||||
|
||||
.viewer-backdrop {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 44%, rgba(47, 56, 53, .42), transparent 58%),
|
||||
#080a09;
|
||||
opacity: calc(1 - var(--viewer-dismiss-progress));
|
||||
transition: opacity 80ms linear;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
inset: 0 0 auto;
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: max(16px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 18px max(18px, env(safe-area-inset-left));
|
||||
background: linear-gradient(to bottom, rgba(5, 7, 6, .72), rgba(5, 7, 6, 0));
|
||||
pointer-events: none;
|
||||
transition: opacity var(--motion-fast) ease;
|
||||
}
|
||||
|
||||
.viewer-heading {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 8px 11px;
|
||||
background: rgba(20, 24, 22, .48);
|
||||
border: 1px solid rgba(255, 255, 255, .09);
|
||||
border-radius: 11px;
|
||||
box-shadow: 0 5px 18px rgba(0, 0, 0, .13);
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, .55);
|
||||
backdrop-filter: blur(13px) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(13px) saturate(1.08);
|
||||
}
|
||||
|
||||
.viewer-heading strong {
|
||||
max-width: min(58vw, 720px);
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-heading span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(238, 243, 241, .68);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
|
||||
.viewer-heading i {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
display: inline-block;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.viewer-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.viewer-control,
|
||||
.viewer-zoom-value,
|
||||
.viewer-nav {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: rgba(248, 250, 249, .88);
|
||||
background: rgba(24, 29, 27, .62);
|
||||
border: 1px solid rgba(255, 255, 255, .11);
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, .2);
|
||||
backdrop-filter: blur(12px) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.08);
|
||||
transition:
|
||||
color var(--motion-fast) ease,
|
||||
background var(--motion-fast) ease,
|
||||
border-color var(--motion-fast) ease,
|
||||
opacity var(--motion-fast) ease,
|
||||
transform var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.viewer-control {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 11px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.viewer-control:hover,
|
||||
.viewer-zoom-value:hover,
|
||||
.viewer-nav:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: rgba(62, 69, 66, .8);
|
||||
border-color: rgba(255, 255, 255, .24);
|
||||
}
|
||||
|
||||
.viewer-control:active,
|
||||
.viewer-zoom-value:active,
|
||||
.viewer-nav:active:not(:disabled) {
|
||||
transform: scale(.93);
|
||||
}
|
||||
|
||||
.viewer-control:focus-visible,
|
||||
.viewer-zoom-value:focus-visible,
|
||||
.viewer-nav:focus-visible,
|
||||
.viewer-load-error:focus-visible {
|
||||
outline-color: #8fd2c9;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.viewer-control:disabled,
|
||||
.viewer-zoom-value:disabled {
|
||||
color: rgba(248, 250, 249, .3);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.viewer-gesture-surface {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer[data-dragging="true"] .viewer-gesture-surface { cursor: grabbing; }
|
||||
|
||||
.viewer-media-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-media {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: #111412;
|
||||
border: 1px solid rgba(255, 255, 255, .045);
|
||||
box-shadow: 0 18px 70px rgba(0, 0, 0, .38);
|
||||
transform-origin: center;
|
||||
will-change: transform;
|
||||
transition: transform 220ms var(--ease-standard);
|
||||
animation: viewer-media-enter 240ms var(--ease-emphasized) both;
|
||||
}
|
||||
|
||||
.image-viewer[data-dragging="true"] .viewer-media { transition: none; }
|
||||
|
||||
.viewer-media img,
|
||||
.viewer-media canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.viewer-thumbnail {
|
||||
filter: saturate(.88) brightness(.84) blur(.35px);
|
||||
transform: scale(1.002);
|
||||
transition: filter 300ms ease, opacity 300ms ease;
|
||||
}
|
||||
|
||||
.viewer-original {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
opacity: 0;
|
||||
transition: opacity 260ms ease;
|
||||
}
|
||||
|
||||
.viewer-original-canvas { object-fit: fill; }
|
||||
|
||||
.image-viewer[data-full-loaded="true"] .viewer-original { opacity: 1; }
|
||||
|
||||
.image-viewer[data-full-loaded="true"] .viewer-thumbnail { opacity: 0; }
|
||||
|
||||
.image-viewer[data-full-failed="true"] .viewer-thumbnail {
|
||||
filter: saturate(.92) brightness(.9);
|
||||
}
|
||||
|
||||
.viewer-nav {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 50%;
|
||||
width: 52px;
|
||||
height: 58px;
|
||||
border-radius: 13px;
|
||||
opacity: .58;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.viewer-nav:hover:not(:disabled),
|
||||
.viewer-nav:focus-visible { opacity: 1; }
|
||||
|
||||
.viewer-nav:active:not(:disabled) { transform: translateY(-50%) scale(.93); }
|
||||
|
||||
.viewer-nav:disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-previous { left: 18px; }
|
||||
.viewer-next { right: 18px; }
|
||||
|
||||
.viewer-bottom-bar {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
right: 0;
|
||||
bottom: max(14px, env(safe-area-inset-bottom));
|
||||
left: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--motion-fast) ease;
|
||||
}
|
||||
|
||||
.viewer-zoom-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 38px 62px 38px;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
background: rgba(17, 21, 19, .7);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 13px;
|
||||
box-shadow: 0 8px 26px rgba(0, 0, 0, .22);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.viewer-zoom-controls .viewer-control {
|
||||
width: 38px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.viewer-zoom-value {
|
||||
width: 62px;
|
||||
height: 30px;
|
||||
color: rgba(248, 250, 249, .75);
|
||||
background: transparent;
|
||||
border-width: 0 1px;
|
||||
border-color: rgba(255, 255, 255, .1);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.viewer-load-error {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: 50%;
|
||||
bottom: max(92px, calc(76px + env(safe-area-inset-bottom)));
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 11px;
|
||||
color: rgba(244, 247, 246, .74);
|
||||
background: rgba(15, 18, 17, .76);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, .22);
|
||||
font-size: 10px;
|
||||
transform: translateX(-50%);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.image-viewer[data-zoomed="true"][data-dragging="true"] .viewer-header,
|
||||
.image-viewer[data-zoomed="true"][data-dragging="true"] .viewer-bottom-bar,
|
||||
.image-viewer[data-zoomed="true"][data-dragging="true"] .viewer-nav {
|
||||
opacity: .22;
|
||||
}
|
||||
|
||||
.viewer-load-error {
|
||||
color: #ffe2de;
|
||||
border-color: rgba(255, 187, 177, .18);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewer-load-error:hover { background: rgba(79, 43, 38, .82); }
|
||||
|
||||
@keyframes viewer-enter {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes viewer-media-enter {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.brand:hover .brand-mark {
|
||||
background: #216e66;
|
||||
@@ -1035,8 +1370,7 @@ select:focus-visible {
|
||||
}
|
||||
.search-popover-panel { height: 46px; }
|
||||
.search-popover-panel input { font-size: 16px; }
|
||||
.masonry,
|
||||
.masonry-column { gap: 4px; }
|
||||
.masonry { --masonry-gap: 4px; }
|
||||
.image-card {
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 1px 4px rgba(21, 31, 27, 0.08);
|
||||
@@ -1050,6 +1384,50 @@ select:focus-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.viewer-header {
|
||||
min-height: calc(64px + env(safe-area-inset-top));
|
||||
gap: 12px;
|
||||
padding:
|
||||
calc(10px + env(safe-area-inset-top))
|
||||
max(10px, env(safe-area-inset-right))
|
||||
16px
|
||||
max(12px, env(safe-area-inset-left));
|
||||
}
|
||||
.viewer-heading {
|
||||
gap: 3px;
|
||||
padding: 7px 9px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.viewer-heading strong {
|
||||
max-width: calc(100vw - 128px);
|
||||
font-size: 13px;
|
||||
}
|
||||
.viewer-heading span { font-size: 10px; }
|
||||
.viewer-header-actions { gap: 6px; }
|
||||
.viewer-header-actions .viewer-control {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.viewer-media {
|
||||
box-shadow: 0 12px 46px rgba(0, 0, 0, .34);
|
||||
}
|
||||
.viewer-nav { display: none; }
|
||||
.viewer-bottom-bar {
|
||||
bottom: max(9px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.viewer-zoom-controls {
|
||||
grid-template-columns: 40px 58px 40px;
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.viewer-zoom-controls .viewer-control {
|
||||
width: 40px;
|
||||
height: 38px;
|
||||
}
|
||||
.viewer-zoom-value { width: 58px; }
|
||||
.viewer-load-error {
|
||||
bottom: max(88px, calc(72px + env(safe-area-inset-bottom)));
|
||||
}
|
||||
.skeleton-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
|
||||
.mobile-nav-layer {
|
||||
position: fixed;
|
||||
@@ -1097,6 +1475,12 @@ select:focus-visible {
|
||||
@keyframes mobile-drawer-out { from { transform: translateX(0); } to { transform: translateX(-100%); } }
|
||||
}
|
||||
|
||||
@media (max-height: 520px) {
|
||||
.viewer-heading span { display: none; }
|
||||
.viewer-bottom-bar { bottom: max(6px, env(safe-area-inset-bottom)); }
|
||||
.viewer-load-error { bottom: max(60px, calc(50px + env(safe-area-inset-bottom))); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
@@ -28,6 +28,7 @@ const THUMBNAIL_EDGE: u32 = 720;
|
||||
const WEBP_QUALITY: f32 = 82.0;
|
||||
const CACHE_VERSION: &str = "720-webp-q82-v1";
|
||||
const THUMBNAIL_FILTER: FilterType = FilterType::Triangle;
|
||||
const LEGACY_VIEWER_CACHE_VERSION: &str = "viewer-3200-webp-q88-fast-v1";
|
||||
const MAX_ATTEMPTS: u8 = 3;
|
||||
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -116,6 +117,16 @@ impl ThumbnailManager {
|
||||
let cache_root = cache_dir.join(CACHE_VERSION);
|
||||
fs::create_dir_all(&cache_root)
|
||||
.with_context(|| format!("cannot create thumbnail cache: {}", cache_root.display()))?;
|
||||
let legacy_viewer_cache = cache_dir.join(LEGACY_VIEWER_CACHE_VERSION);
|
||||
if legacy_viewer_cache.is_dir()
|
||||
&& let Err(error) = fs::remove_dir_all(&legacy_viewer_cache)
|
||||
{
|
||||
warn!(
|
||||
path = %legacy_viewer_cache.display(),
|
||||
%error,
|
||||
"cannot remove obsolete viewer cache"
|
||||
);
|
||||
}
|
||||
purge_temporary_files(&cache_root);
|
||||
Ok(Arc::new(Self {
|
||||
cache_root,
|
||||
@@ -627,6 +638,19 @@ mod tests {
|
||||
assert_eq!(generated.dimensions(), (720, 360));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_removes_the_obsolete_second_preview_cache() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let legacy = temp.path().join(LEGACY_VIEWER_CACHE_VERSION);
|
||||
fs::create_dir(&legacy).unwrap();
|
||||
fs::write(legacy.join("preview.webp"), [1; 32]).unwrap();
|
||||
|
||||
let manager = ThumbnailManager::new(temp.path().to_path_buf()).unwrap();
|
||||
|
||||
assert!(manager.cache_root.is_dir());
|
||||
assert!(!legacy.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_only_the_single_high_quality_variant() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
+71
-1
@@ -118,6 +118,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/images", get(images))
|
||||
.route("/api/status", get(thumbnail_status))
|
||||
.route("/api/images/{id}/thumbnail", get(thumbnail))
|
||||
.route("/api/images/{id}/original", get(original))
|
||||
.route("/assets/{version}/app.js", get(app_js))
|
||||
.route("/assets/{version}/app.css", get(app_css))
|
||||
.fallback(frontend)
|
||||
@@ -329,6 +330,33 @@ async fn thumbnail(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn original(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
request: Request,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Resolve the opaque id through the index instead of accepting a filesystem path
|
||||
// from the request. Cloning the path also avoids holding the read lock while the
|
||||
// file is streamed to a slow client.
|
||||
let path = {
|
||||
let index = state.index.read().await;
|
||||
index
|
||||
.images
|
||||
.iter()
|
||||
.find(|image| image.id == id)
|
||||
.map(|image| image.path.clone())
|
||||
.ok_or(StatusCode::NOT_FOUND)?
|
||||
};
|
||||
|
||||
serve_file(
|
||||
path,
|
||||
request,
|
||||
&format!("\"original-{id}\""),
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
path: std::path::PathBuf,
|
||||
request: Request,
|
||||
@@ -617,7 +645,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_api_and_thumbnail_routes_return_not_found() {
|
||||
async fn unknown_api_and_image_routes_return_not_found() {
|
||||
assert_eq!(
|
||||
request("/api/does-not-exist").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
@@ -626,9 +654,51 @@ mod tests {
|
||||
request("/api/images/missing/thumbnail").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
request("/api/images/missing/original").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(request("/missing").await.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn indexed_images_expose_original_with_immutable_cache() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
std::fs::create_dir(&gallery).unwrap();
|
||||
image::RgbImage::new(20, 10)
|
||||
.save(gallery.join("image.png"))
|
||||
.unwrap();
|
||||
let index = crate::gallery::scan_gallery(&gallery, None).unwrap();
|
||||
let id = index.images[0].id.clone();
|
||||
let thumbnails = ThumbnailManager::new(temp.path().join("cache")).unwrap();
|
||||
thumbnails.reconcile(&index.images);
|
||||
let app = router(AppState {
|
||||
index: Arc::new(RwLock::new(index)),
|
||||
thumbnails,
|
||||
});
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/images/{id}/original"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!body.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn versioned_assets_are_immutable() {
|
||||
let response = request(&format!("/assets/{ASSET_VERSION}/app.js")).await;
|
||||
|
||||
Reference in new issue
Block a user