Release v0.1.4

添加原图查看器、相邻预取与桌面/移动端交互
This commit is contained in:
adminroot committed 2026-08-23 04:47:33 +00:00
1 parent e646d6762a
commit deb12eb43f
12 files changed
+2144 -203

No files matched your search

Generated
+1 -1
View File
@@ -618,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pixhelf"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "pixhelf"
version = "0.1.3"
version = "0.1.4"
edition = "2024"
build = "build.rs"
+1 -1
View File
@@ -14,7 +14,7 @@ HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图
首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。点击图片后,查看器会先显示这份缩略图,再加载原图,并提前预取相邻原图;超大原图会在浏览器内解码到 GPU 安全画布,不会生成第二套预览缓存。查看器支持键盘切换、滚轮缩放,以及移动端滑动、双击和双指缩放。缓存目录必须位于图库目录之外。
## 构建
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+289 -4
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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
View File
@@ -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,
+24
View File
@@ -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
View File
@@ -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;