diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a957758..12b1a4e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -20,6 +20,7 @@ For day-to-day coding conventions and established patterns, see [CONTRIBUTING.md
- [3.7 Settings & Configuration System](#37-settings--configuration-system)
- [3.8 Task Scheduling Subsystem](#38-task-scheduling-subsystem)
- [3.9 Statistics Subsystem](#39-statistics-subsystem)
+ - [3.10 Lightbox Media Viewer Subsystem](#310-lightbox-media-viewer-subsystem)
4. [Data Flow](#4-data-flow)
- [4.1 Scrape → Scan → Display](#41-scrape--scan--display)
- [4.2 Client-Side Data Fetching](#42-client-side-data-fetching)
@@ -119,7 +120,7 @@ web-gallery/
│ │ ├── error.tsx # Root error boundary
│ │ └── not-found.tsx # 404 page
│ ├── components/ # Shared UI components
-│ │ ├── Lightbox.tsx # Full-screen media viewer
+│ │ ├── Lightbox.tsx # Full-screen media viewer (fit modes, zoom/pan, touch gestures)
│ │ ├── MasonryGrid.tsx # CSS columns masonry layout
│ │ ├── Navbar.tsx # Top navigation bar + theme toggle
│ │ ├── ThemeProvider.tsx # Dark/light theme via data-theme attribute
@@ -373,6 +374,22 @@ The Statistics Subsystem provides pre-computed counters and historical growth me
- **Proportional Timeline Distribution**: For historical charts, cumulative growth is tracked daily in `statistics_history`. If a metric lacks historical timestamps (such as tags or user entries), the repository maps them proportionally to the daily posts import velocity to provide clean growth curves.
- **REST Endpoints & Client Rendering**: The dashboard fetches its data on-demand from `/api/statistics` for modular caching and instant filters (date granularity, range limits, active page sorting).
+### 3.10 Lightbox Media Viewer Subsystem
+
+**Location**: [`src/components/Lightbox.tsx`](./src/components/Lightbox.tsx), [`src/hooks/useLightbox.ts`](./src/hooks/useLightbox.ts)
+
+The Lightbox subsystem provides interactive, full-screen media viewing with configurable zoom, panning, fit modes, navigation, and gesture handling:
+
+- **Fit Mode Cycling**: Supports `fitBoth` (contain within viewport bounds), `fitWidth` (expand to full container width), and `fitHeight` (expand to full container height). Changing fit modes automatically resets zoom level to 100% and pan offset to `{ x: 0, y: 0 }`.
+- **Directional Clamping & Pan Boundaries**: Drag panning via mouse or touch is constrained by `clampPanOffset`:
+ - `fitWidth` mode locks horizontal panning (`x: 0`) and allows vertical panning bounded to keep rendered top/bottom image edges within the viewport.
+ - `fitHeight` mode locks vertical panning (`y: 0`) and allows horizontal panning bounded to keep rendered left/right image edges within the viewport.
+ - Zoomed mode (`zoom > 1.0`) allows two-axis panning bounded so image edges can pan up to half the container dimension.
+ - `fitBoth` at 100% zoom disables panning entirely, preserving swipe-to-navigate.
+- **Multi-Touch Pinch-to-Zoom**: Detects two-finger touch events (`onTouchStart`, `onTouchMove`, `onTouchEnd`) to dynamically update zoom level based on touch distance ratios. Uses initial touch midpoint anchoring (`initialPinchCenterRef`) to calculate translation shifts during scaling.
+- **Auto-Hide Controls Framework**: Supports a persistent toggle button (`Eye`/`EyeOff`) and configurable inactivity auto-hide timers (`autoHideDelay`). Timer resets on pointer, click, touch, wheel, keyboard, or navigation activity. Pressing `Escape` while controls are hidden restores control visibility before closing on second press.
+- **Full-Height Navigation Overlay**: Replaces small target buttons with full-height left and right click/touch zones (`navZonePrev`, `navZoneNext`) for effortless media cycling.
+
---
## 4. Data Flow
@@ -584,6 +601,12 @@ Separating fast-access data (database, scraper state) from bulk media allows ope
### FIFO schedule execution for SQLite safety
To prevent multiple concurrent scheduled scraping tasks from locking or corrupting the SQLite database, all scheduled runs are serialized through a sequential FIFO queue. The next scheduled task begins execution only after the current scraper process completes and its database synchronization finishes.
+### Scoped browser zoom prevention over global user-scalable=no
+Disabling viewport zoom globally via `user-scalable=no` meta tags violates WCAG accessibility standards. To prevent mobile pinch gestures from zooming the entire web application layout while interacting with the media viewer, `Lightbox.tsx` attaches a non-passive `touchmove` event listener (`{ passive: false }`) scoped exclusively to the Lightbox overlay element (`overlayRef`), calling `preventDefault()` only when multi-touch gestures occur.
+
+### Pan offset divided by zoom factor in CSS transform
+CSS `transform` matrix operations evaluate transformations right-to-left (`scale()` then `translate()`). In `Lightbox.tsx`, inline styling applies `scale(${zoom}) translate(${panOffset.x / zoom}px, ${panOffset.y / zoom}px)`. Dividing the raw screen-pixel drag offset by the zoom level converts pan deltas back into pre-scaled coordinate space, ensuring 1:1 physical cursor-to-image tracking at any zoom magnification level.
+
---
## 9. Testing Architecture
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6afab73..bbdc101 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -116,7 +116,7 @@ Frontend types are in `src/types/`:
| `autocomplete.ts` | `AutocompleteSuggestion`, `AutocompleteResponse` definitions |
| `playlist.ts` | `Playlist`, playlist-related types |
| `posts.ts` | `TimelinePost`, post-related types |
-| `settings.ts` | `SystemSettings`, `AppSettings` definitions |
+| `settings.ts` | `SystemSettings`, `AppSettings` (including Lightbox fit, zoom, and auto-hide config) definitions |
| `source.ts` | `Source`, source-related types |
| `statistics.ts` | `LibraryStatistics`, `StatisticsHistoryPoint`, etc. |
| `users.ts` | `TwitterUser`, `PixivUser` |
diff --git a/src/app/gallery/page-client.tsx b/src/app/gallery/page-client.tsx
index 97881d2..df72edc 100644
--- a/src/app/gallery/page-client.tsx
+++ b/src/app/gallery/page-client.tsx
@@ -29,6 +29,12 @@ export default function GalleryPageClient({
loopVideos,
autoplayVideos,
muteAutoplayVideos,
+ autoHideControls,
+ autoHideDelay,
+ lightboxFitMode,
+ lightboxZoomMin,
+ lightboxZoomMax,
+ lightboxZoomStep,
playlistId,
}: {
initialItems: GalleryGroup[];
@@ -41,6 +47,12 @@ export default function GalleryPageClient({
loopVideos: boolean;
autoplayVideos: boolean;
muteAutoplayVideos: boolean;
+ autoHideControls?: boolean;
+ autoHideDelay?: number;
+ lightboxFitMode?: "fitBoth" | "fitWidth" | "fitHeight";
+ lightboxZoomMin?: number;
+ lightboxZoomMax?: number;
+ lightboxZoomStep?: number;
playlistId?: number;
}) {
return (
@@ -55,6 +67,12 @@ export default function GalleryPageClient({
loopVideos={loopVideos}
autoplayVideos={autoplayVideos}
muteAutoplayVideos={muteAutoplayVideos}
+ autoHideControls={autoHideControls}
+ autoHideDelay={autoHideDelay}
+ lightboxFitMode={lightboxFitMode}
+ lightboxZoomMin={lightboxZoomMin}
+ lightboxZoomMax={lightboxZoomMax}
+ lightboxZoomStep={lightboxZoomStep}
playlistId={playlistId}
/>
);
@@ -71,6 +89,12 @@ function GalleryPageContent({
loopVideos,
autoplayVideos,
muteAutoplayVideos,
+ autoHideControls,
+ autoHideDelay,
+ lightboxFitMode,
+ lightboxZoomMin,
+ lightboxZoomMax,
+ lightboxZoomStep,
playlistId,
}: {
initialItems: GalleryGroup[];
@@ -83,6 +107,12 @@ function GalleryPageContent({
loopVideos: boolean;
autoplayVideos: boolean;
muteAutoplayVideos: boolean;
+ autoHideControls?: boolean;
+ autoHideDelay?: number;
+ lightboxFitMode?: "fitBoth" | "fitWidth" | "fitHeight";
+ lightboxZoomMin?: number;
+ lightboxZoomMax?: number;
+ lightboxZoomStep?: number;
playlistId?: number;
}) {
const [columnCount, setColumnCount] = useState(4);
@@ -369,6 +399,12 @@ function GalleryPageContent({
onRefetch={handleRefetchSingle}
loopVideos={loopVideos}
isPageLoading={isPageLoading}
+ autoHideControls={autoHideControls}
+ autoHideDelay={autoHideDelay}
+ fitMode={lightboxFitMode}
+ zoomMin={lightboxZoomMin}
+ zoomMax={lightboxZoomMax}
+ zoomStep={lightboxZoomStep}
onTagClick={(tagName) => {
setSearchQuery(`tag:${tagName} `);
closeLightbox();
diff --git a/src/app/gallery/page.tsx b/src/app/gallery/page.tsx
index 65a4da9..180a3b5 100644
--- a/src/app/gallery/page.tsx
+++ b/src/app/gallery/page.tsx
@@ -47,6 +47,12 @@ export default async function GalleryPage({
loopVideos={settings.loopVideos}
autoplayVideos={settings.autoplayVideos ?? false}
muteAutoplayVideos={settings.muteAutoplayVideos ?? true}
+ autoHideControls={settings.lightboxAutoHideControls ?? false}
+ autoHideDelay={settings.lightboxAutoHideDelay ?? 3}
+ lightboxFitMode={settings.lightboxFitMode ?? "fitBoth"}
+ lightboxZoomMin={settings.lightboxZoomMin ?? 50}
+ lightboxZoomMax={settings.lightboxZoomMax ?? 200}
+ lightboxZoomStep={settings.lightboxZoomStep ?? 25}
playlistId={
playlistId && !Number.isNaN(playlistId) ? playlistId : undefined
}
diff --git a/src/app/settings/page-client.tsx b/src/app/settings/page-client.tsx
index 6bb1124..e0aab82 100644
--- a/src/app/settings/page-client.tsx
+++ b/src/app/settings/page-client.tsx
@@ -5,6 +5,7 @@ import {
CheckCircle,
Edit3,
Globe,
+ Maximize2,
Play,
RotateCcw,
Save,
@@ -159,6 +160,63 @@ export default function SettingsPageClient({
return;
}
+ if (settings.app.lightboxZoomMin > settings.app.lightboxZoomMax) {
+ setNotification({
+ type: "error",
+ message: "Lightbox minimum zoom cannot be greater than maximum zoom.",
+ });
+ setIsSaving(false);
+ return;
+ }
+
+ if (
+ settings.app.lightboxZoomMin < 10 ||
+ settings.app.lightboxZoomMin > 100
+ ) {
+ setNotification({
+ type: "error",
+ message: "Lightbox minimum zoom must be between 10% and 100%.",
+ });
+ setIsSaving(false);
+ return;
+ }
+
+ if (
+ settings.app.lightboxZoomMax < 100 ||
+ settings.app.lightboxZoomMax > 1000
+ ) {
+ setNotification({
+ type: "error",
+ message: "Lightbox maximum zoom must be between 100% and 1000%.",
+ });
+ setIsSaving(false);
+ return;
+ }
+
+ if (
+ settings.app.lightboxZoomStep < 5 ||
+ settings.app.lightboxZoomStep > 100
+ ) {
+ setNotification({
+ type: "error",
+ message: "Lightbox zoom step must be between 5% and 100%.",
+ });
+ setIsSaving(false);
+ return;
+ }
+
+ if (
+ settings.app.lightboxAutoHideDelay < 1 ||
+ settings.app.lightboxAutoHideDelay > 30
+ ) {
+ setNotification({
+ type: "error",
+ message: "Lightbox auto-hide delay must be between 1 and 30 seconds.",
+ });
+ setIsSaving(false);
+ return;
+ }
+
if (
settings.scraper.sleepMin < 0 ||
settings.scraper.sleepMax < 0 ||
@@ -593,6 +651,180 @@ export default function SettingsPageClient({
+
+
+ Lightbox Settings
+
+
+
+
+
+ Default Fit Mode
+
+
+ handleAppChange(
+ "lightboxFitMode",
+ e.target.value as "fitBoth" | "fitWidth" | "fitHeight",
+ )
+ }
+ className={styles.select}
+ >
+ Fit Both (Default)
+ Fit Width
+ Fit Height
+
+
+ Initial media sizing inside the lightbox container.
+
+
+
+
+
+ Minimum Zoom Level (%)
+
+
+ handleAppChange(
+ "lightboxZoomMin",
+ parseInt(e.target.value, 10) || 25,
+ )
+ }
+ className={styles.input}
+ required
+ />
+
+ Lower scale bound when zooming media in lightbox (10-100%).
+
+
+
+
+
+ Maximum Zoom Level (%)
+
+
+ handleAppChange(
+ "lightboxZoomMax",
+ parseInt(e.target.value, 10) || 500,
+ )
+ }
+ className={styles.input}
+ required
+ />
+
+ Upper scale bound when zooming media in lightbox
+ (100-1000%).
+
+
+
+
+
+ Zoom Step Increment (%)
+
+
+ handleAppChange(
+ "lightboxZoomStep",
+ parseInt(e.target.value, 10) || 25,
+ )
+ }
+ className={styles.input}
+ required
+ />
+
+ Percentage change per zoom click or scroll wheel step
+ (5-100%).
+
+
+
+
+
+
+
+ Auto-Hide Lightbox Controls
+
+
+ Automatically fade out lightbox navigation and action
+ controls after a period of mouse inactivity.
+
+
+
+
+ handleAppChange(
+ "lightboxAutoHideControls",
+ e.target.checked,
+ )
+ }
+ />
+
+
+
+
+
+
+ Auto-Hide Inactivity Delay (seconds)
+
+
+ handleAppChange(
+ "lightboxAutoHideDelay",
+ parseInt(e.target.value, 10) || 3,
+ )
+ }
+ className={styles.input}
+ required
+ />
+
+ Inactivity delay in seconds before controls automatically hide
+ (1-30s).
+
+
+
Library Statistics Page
diff --git a/src/app/timeline/page-client.tsx b/src/app/timeline/page-client.tsx
index 7f9ecea..2918b03 100644
--- a/src/app/timeline/page-client.tsx
+++ b/src/app/timeline/page-client.tsx
@@ -30,6 +30,12 @@ export default function TimelinePageClient({
muteAutoplayVideos,
condensePostText,
condensePostLines,
+ autoHideControls,
+ autoHideDelay,
+ lightboxFitMode,
+ lightboxZoomMin,
+ lightboxZoomMax,
+ lightboxZoomStep,
}: {
initialPosts: TimelinePost[];
initialNextCursor: string | null;
@@ -43,6 +49,12 @@ export default function TimelinePageClient({
muteAutoplayVideos: boolean;
condensePostText: boolean;
condensePostLines: number;
+ autoHideControls?: boolean;
+ autoHideDelay?: number;
+ lightboxFitMode?: "fitBoth" | "fitWidth" | "fitHeight";
+ lightboxZoomMin?: number;
+ lightboxZoomMax?: number;
+ lightboxZoomStep?: number;
}) {
return (
);
}
@@ -75,6 +93,12 @@ function TimelinePageContent({
muteAutoplayVideos,
condensePostText,
condensePostLines,
+ autoHideControls,
+ autoHideDelay,
+ lightboxFitMode,
+ lightboxZoomMin,
+ lightboxZoomMax,
+ lightboxZoomStep,
}: {
initialPosts: TimelinePost[];
initialNextCursor: string | null;
@@ -88,6 +112,12 @@ function TimelinePageContent({
muteAutoplayVideos: boolean;
condensePostText: boolean;
condensePostLines: number;
+ autoHideControls?: boolean;
+ autoHideDelay?: number;
+ lightboxFitMode?: "fitBoth" | "fitWidth" | "fitHeight";
+ lightboxZoomMin?: number;
+ lightboxZoomMax?: number;
+ lightboxZoomStep?: number;
}) {
const [suppressSearch, setSuppressSearch] = useState(false);
@@ -311,6 +341,12 @@ function TimelinePageContent({
onDelete={undefined}
loopVideos={loopVideos}
isPageLoading={isPageLoading}
+ autoHideControls={autoHideControls}
+ autoHideDelay={autoHideDelay}
+ fitMode={lightboxFitMode}
+ zoomMin={lightboxZoomMin}
+ zoomMax={lightboxZoomMax}
+ zoomStep={lightboxZoomStep}
/>
)}
diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx
index 65de819..46ec10b 100644
--- a/src/app/timeline/page.tsx
+++ b/src/app/timeline/page.tsx
@@ -45,6 +45,12 @@ export default async function TimelinePage({
muteAutoplayVideos={muteAutoplayVideos}
condensePostText={settings.condensePostText ?? true}
condensePostLines={settings.condensePostLines ?? 2}
+ autoHideControls={settings.lightboxAutoHideControls ?? false}
+ autoHideDelay={settings.lightboxAutoHideDelay ?? 3}
+ lightboxFitMode={settings.lightboxFitMode ?? "fitBoth"}
+ lightboxZoomMin={settings.lightboxZoomMin ?? 50}
+ lightboxZoomMax={settings.lightboxZoomMax ?? 200}
+ lightboxZoomStep={settings.lightboxZoomStep ?? 25}
/>
);
}
diff --git a/src/components/Lightbox.module.css b/src/components/Lightbox.module.css
index e0e59b1..7bbc3c2 100644
--- a/src/components/Lightbox.module.css
+++ b/src/components/Lightbox.module.css
@@ -13,6 +13,7 @@
animation: fadeIn 0.3s forwards;
color: hsl(var(--foreground));
font-family: system-ui, -apple-system, sans-serif;
+ touch-action: none;
}
@keyframes fadeIn {
@@ -28,6 +29,9 @@
align-items: center;
justify-content: center;
padding: 20px;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
}
.mediaWrapper {
@@ -38,6 +42,8 @@
align-items: center;
justify-content: center;
pointer-events: none;
+ overflow: hidden;
+ touch-action: none;
}
.image {
@@ -47,8 +53,41 @@
max-height: 100%;
object-fit: contain;
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
- transition: transform 0.3s ease;
pointer-events: auto;
+ transform-origin: center center;
+ touch-action: none;
+}
+
+.fitBoth {
+ max-width: 100%;
+ max-height: 100%;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+}
+
+.fitWidth {
+ width: 100% !important;
+ max-width: 100% !important;
+ height: auto !important;
+ max-height: none !important;
+ object-fit: contain;
+}
+
+.fitHeight {
+ height: 100% !important;
+ max-height: 100% !important;
+ width: auto !important;
+ max-width: none !important;
+ object-fit: contain;
+}
+
+.grab {
+ cursor: grab;
+}
+
+.grabbing {
+ cursor: grabbing;
}
.video {
@@ -243,6 +282,38 @@
margin-top: 10px;
}
+.persistentToggleBtn {
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ z-index: 300;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: hsl(var(--secondary));
+ border: 1px solid hsl(var(--border));
+ color: hsl(var(--foreground));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ opacity: 0.8;
+ transition:
+ opacity 0.3s ease,
+ transform 0.2s ease,
+ background-color 0.2s ease;
+}
+
+.persistentToggleBtn:hover {
+ background: hsl(var(--accent));
+ opacity: 1;
+ transform: scale(1.1);
+}
+
+.persistentToggleBtn.autoHidden {
+ opacity: 0.3;
+}
+
.controls {
position: absolute;
top: 20px;
@@ -250,6 +321,24 @@
display: flex;
gap: 10px;
z-index: 10;
+ max-width: calc(100% - 80px);
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ transition:
+ opacity 0.3s ease,
+ transform 0.3s ease;
+}
+
+.controls[data-visible="false"],
+.navZone[data-visible="false"] {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.controls[data-visible="true"],
+.navZone[data-visible="true"] {
+ opacity: 1;
+ pointer-events: auto;
}
.iconButton {
@@ -273,6 +362,17 @@
transform: scale(1.1);
}
+.zoomResetButton {
+ width: auto !important;
+ padding: 0 12px !important;
+ border-radius: 20px !important;
+}
+
+.zoomText {
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
.deleteDBButton:hover {
background: hsl(var(--color-warning));
color: white;
@@ -296,38 +396,49 @@
border: 1px solid hsl(var(--border));
}
-.navButton {
+.navZone {
position: absolute;
- top: 50%;
- transform: translateY(-50%);
- background: hsl(var(--secondary));
- color: hsl(var(--foreground));
- border: none;
- width: 50px;
- height: 50px;
- border-radius: 50%;
+ top: 0;
+ bottom: 0;
+ height: 100%;
+ width: 60px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
- transition: all 0.2s;
+ background: transparent;
+ border: none;
z-index: 5;
- font-size: 1.5rem;
- opacity: 0.7;
+ transition: background 0.25s ease;
}
-.navButton:hover {
- background: hsl(var(--accent));
- opacity: 1;
- transform: translateY(-50%) scale(1.1);
+.navZonePrev {
+ left: 0;
}
-.prevButton {
- left: 20px;
+.navZoneNext {
+ right: 0;
}
-.nextButton {
- right: 20px;
+.navZonePrev:hover {
+ background: linear-gradient(to right, hsl(0 0% 0% / 0.4), transparent);
+}
+
+.navZoneNext:hover {
+ background: linear-gradient(to left, hsl(0 0% 0% / 0.4), transparent);
+}
+
+.navChevron {
+ color: hsl(var(--foreground));
+ opacity: 0.3;
+ transition:
+ opacity 0.2s ease,
+ transform 0.2s ease;
+}
+
+.navZone:hover .navChevron {
+ opacity: 1;
+ transform: scale(1.15);
}
/* Scrollbar styling for sidebar */
@@ -425,18 +536,8 @@
text-align: center;
}
- .navButton {
- width: 40px;
- height: 40px;
- font-size: 1.2rem;
- }
-
- .prevButton {
- left: 10px;
- }
-
- .nextButton {
- right: 10px;
+ .navZone {
+ width: 44px;
}
.controls {
diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx
index 6fc989d..ee3cdd6 100644
--- a/src/components/Lightbox.tsx
+++ b/src/components/Lightbox.tsx
@@ -1,5 +1,16 @@
"use client";
+import {
+ ChevronLeft,
+ ChevronRight,
+ Eye,
+ EyeOff,
+ Maximize2,
+ MoveHorizontal,
+ MoveVertical,
+ ZoomIn,
+ ZoomOut,
+} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -40,6 +51,12 @@ interface LightboxProps {
onRefetch?: (postId: number) => Promise;
loopVideos?: boolean;
isPageLoading?: boolean;
+ autoHideControls?: boolean;
+ autoHideDelay?: number;
+ fitMode?: "fitBoth" | "fitWidth" | "fitHeight";
+ zoomMin?: number;
+ zoomMax?: number;
+ zoomStep?: number;
onTagClick?: (tagName: string) => void;
onUserClick?: (userName: string) => void;
}
@@ -59,11 +76,27 @@ export default function Lightbox({
onRefetch,
loopVideos,
isPageLoading = false,
+ autoHideControls = false,
+ autoHideDelay = 3,
+ fitMode = "fitBoth",
+ zoomMin = 50,
+ zoomMax = 200,
+ zoomStep = 25,
onTagClick,
onUserClick,
}: LightboxProps) {
const { item } = row;
const [showInfo, setShowInfo] = useState(true);
+ const [controlsVisible, setControlsVisible] = useState(true);
+ const [isAutoHidden, setIsAutoHidden] = useState(false);
+ const [currentFitMode, setCurrentFitMode] = useState<
+ "fitBoth" | "fitWidth" | "fitHeight"
+ >(fitMode);
+ const [zoom, setZoom] = useState(1.0);
+ const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStartRef = useRef<{ x: number; y: number } | null>(null);
+
const [postTags, setPostTags] = useState([]);
const [isAddingTag, setIsAddingTag] = useState(false);
const [isRecentlyMounted, setIsRecentlyMounted] = useState(true);
@@ -74,6 +107,346 @@ export default function Lightbox({
[],
);
const router = useRouter();
+ const inactivityTimerRef = useRef(null);
+
+ useEffect(() => {
+ setCurrentFitMode(fitMode);
+ }, [fitMode]);
+
+ const minZoomVal = zoomMin / 100;
+ const maxZoomVal = zoomMax / 100;
+ const stepZoomVal = zoomStep / 100;
+
+ const isPannable = zoom > 1.0 || currentFitMode !== "fitBoth";
+ const mediaWrapperRef = useRef(null);
+ const imageRef = useRef(null);
+ const touchDragStartRef = useRef<{ x: number; y: number } | null>(null);
+ const dragDistanceRef = useRef(0);
+
+ const clampPanOffset = useCallback(
+ (rawX: number, rawY: number) => {
+ if (zoom <= 1.0 && currentFitMode === "fitBoth") {
+ return { x: 0, y: 0 };
+ }
+
+ const wrapper = mediaWrapperRef.current;
+ const img = imageRef.current;
+
+ if (zoom > 1.0) {
+ // Zoom mode: pan allowed in both axes, bounded so an edge can only pan to half the screen (center).
+ const wrapperWidth = wrapper ? wrapper.clientWidth : window.innerWidth;
+ const wrapperHeight = wrapper
+ ? wrapper.clientHeight
+ : window.innerHeight;
+ const imgWidth = img ? img.offsetWidth * zoom : wrapperWidth * zoom;
+ const imgHeight = img ? img.offsetHeight * zoom : wrapperHeight * zoom;
+
+ const maxPanX = Math.max(0, imgWidth / 2);
+ const maxPanY = Math.max(0, imgHeight / 2);
+
+ return {
+ x: Math.max(-maxPanX, Math.min(maxPanX, rawX)),
+ y: Math.max(-maxPanY, Math.min(maxPanY, rawY)),
+ };
+ }
+
+ if (currentFitMode === "fitWidth") {
+ // Fit width mode: ONLY pan up/down (X locked to 0).
+ // Bounds: keep fitted top/bottom edges on screen.
+ if (!wrapper || !img) return { x: 0, y: 0 };
+
+ const containerHeight = wrapper.clientHeight;
+ const renderedHeight = img.offsetHeight;
+
+ if (renderedHeight <= containerHeight) {
+ return { x: 0, y: 0 };
+ }
+
+ const maxPanY = (renderedHeight - containerHeight) / 2;
+ return {
+ x: 0,
+ y: Math.max(-maxPanY, Math.min(maxPanY, rawY)),
+ };
+ }
+
+ if (currentFitMode === "fitHeight") {
+ // Fit height mode: ONLY pan left/right (Y locked to 0).
+ // Bounds: keep fitted left/right edges on screen.
+ if (!wrapper || !img) return { x: 0, y: 0 };
+
+ const containerWidth = wrapper.clientWidth;
+ const renderedWidth = img.offsetWidth;
+
+ if (renderedWidth <= containerWidth) {
+ return { x: 0, y: 0 };
+ }
+
+ const maxPanX = (renderedWidth - containerWidth) / 2;
+ return {
+ x: Math.max(-maxPanX, Math.min(maxPanX, rawX)),
+ y: 0,
+ };
+ }
+
+ return { x: 0, y: 0 };
+ },
+ [zoom, currentFitMode],
+ );
+
+ const cycleFitMode = () => {
+ setZoom(1.0);
+ setPanOffset({ x: 0, y: 0 });
+ setCurrentFitMode((prev) => {
+ if (prev === "fitBoth") return "fitWidth";
+ if (prev === "fitWidth") return "fitHeight";
+ return "fitBoth";
+ });
+ };
+
+ const handleZoomIn = () => {
+ setZoom((prev) =>
+ Math.min(maxZoomVal, parseFloat((prev + stepZoomVal).toFixed(2))),
+ );
+ };
+
+ const handleZoomOut = () => {
+ setZoom((prev) =>
+ Math.max(minZoomVal, parseFloat((prev - stepZoomVal).toFixed(2))),
+ );
+ };
+
+ const handleResetZoom = () => {
+ setZoom(1.0);
+ setPanOffset({ x: 0, y: 0 });
+ };
+
+ const handleWheel = (e: React.WheelEvent) => {
+ if (item.mediaType === "video") return;
+ const delta = e.deltaY < 0 ? stepZoomVal : -stepZoomVal;
+ setZoom((prev) => {
+ const next = parseFloat((prev + delta).toFixed(2));
+ const clampedZoom = Math.min(maxZoomVal, Math.max(minZoomVal, next));
+ if (clampedZoom <= 1.0 && currentFitMode === "fitBoth") {
+ setPanOffset({ x: 0, y: 0 });
+ }
+ return clampedZoom;
+ });
+ };
+
+ const isPinchingRef = useRef(false);
+ const initialPinchDistRef = useRef(null);
+ const initialPinchZoomRef = useRef(1.0);
+ const initialPinchPanRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const initialPinchCenterRef = useRef<{ x: number; y: number } | null>(null);
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (!isPannable) return;
+ e.preventDefault();
+ dragDistanceRef.current = 0;
+ setIsDragging(true);
+ dragStartRef.current = {
+ x: e.clientX - panOffset.x,
+ y: e.clientY - panOffset.y,
+ };
+ };
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ if (!isDragging || !dragStartRef.current || !isPannable) return;
+ e.preventDefault();
+ dragDistanceRef.current += 1;
+ const rawX = e.clientX - dragStartRef.current.x;
+ const rawY = e.clientY - dragStartRef.current.y;
+ setPanOffset(clampPanOffset(rawX, rawY));
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ dragStartRef.current = null;
+ };
+
+ const handleTouchStartImage = (e: React.TouchEvent) => {
+ if (item.mediaType === "video" || item.mediaType === "text") return;
+
+ if (e.touches.length === 2) {
+ e.preventDefault();
+ e.stopPropagation();
+ isPinchingRef.current = true;
+ setIsDragging(true);
+
+ const t1 = e.touches[0];
+ const t2 = e.touches[1];
+
+ const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
+ initialPinchDistRef.current = dist;
+ initialPinchZoomRef.current = zoom;
+ initialPinchPanRef.current = { ...panOffset };
+ initialPinchCenterRef.current = {
+ x: (t1.clientX + t2.clientX) / 2,
+ y: (t1.clientY + t2.clientY) / 2,
+ };
+ return;
+ }
+
+ if (e.touches.length === 1 && isPannable) {
+ const touch = e.touches[0];
+ dragDistanceRef.current = 0;
+ touchDragStartRef.current = {
+ x: touch.clientX - panOffset.x,
+ y: touch.clientY - panOffset.y,
+ };
+ }
+ };
+
+ const handleTouchMoveImage = (e: React.TouchEvent) => {
+ if (item.mediaType === "video" || item.mediaType === "text") return;
+
+ if (
+ e.touches.length === 2 &&
+ isPinchingRef.current &&
+ initialPinchDistRef.current &&
+ initialPinchCenterRef.current
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
+ dragDistanceRef.current += 1;
+
+ const t1 = e.touches[0];
+ const t2 = e.touches[1];
+ const newDist = Math.hypot(
+ t1.clientX - t2.clientX,
+ t1.clientY - t2.clientY,
+ );
+
+ const scaleRatio = newDist / initialPinchDistRef.current;
+ const targetZoom = parseFloat(
+ (initialPinchZoomRef.current * scaleRatio).toFixed(2),
+ );
+ const clampedZoom = Math.min(
+ maxZoomVal,
+ Math.max(minZoomVal, targetZoom),
+ );
+
+ const currentCenter = {
+ x: (t1.clientX + t2.clientX) / 2,
+ y: (t1.clientY + t2.clientY) / 2,
+ };
+ const centerShiftX = currentCenter.x - initialPinchCenterRef.current.x;
+ const centerShiftY = currentCenter.y - initialPinchCenterRef.current.y;
+
+ const rawX = initialPinchPanRef.current.x + centerShiftX;
+ const rawY = initialPinchPanRef.current.y + centerShiftY;
+
+ setZoom(clampedZoom);
+ setPanOffset(clampPanOffset(rawX, rawY));
+ return;
+ }
+
+ if (
+ e.touches.length === 1 &&
+ touchDragStartRef.current &&
+ isPannable &&
+ !isPinchingRef.current
+ ) {
+ const touch = e.touches[0];
+ dragDistanceRef.current += 1;
+ setIsDragging(true);
+ const rawX = touch.clientX - touchDragStartRef.current.x;
+ const rawY = touch.clientY - touchDragStartRef.current.y;
+ setPanOffset(clampPanOffset(rawX, rawY));
+ }
+ };
+
+ const handleTouchEndImage = (e: React.TouchEvent) => {
+ if (isPinchingRef.current) {
+ e.stopPropagation();
+ if (e.touches.length < 2) {
+ isPinchingRef.current = false;
+ initialPinchDistRef.current = null;
+ initialPinchCenterRef.current = null;
+ setIsDragging(false);
+
+ if (zoom <= 1.0 && currentFitMode === "fitBoth") {
+ setZoom(1.0);
+ setPanOffset({ x: 0, y: 0 });
+ }
+ }
+ return;
+ }
+
+ if (touchDragStartRef.current) {
+ touchDragStartRef.current = null;
+ setIsDragging(false);
+ }
+ };
+
+ const resetInactivityTimer = useCallback(() => {
+ setIsAutoHidden(false);
+ setControlsVisible(true);
+
+ if (inactivityTimerRef.current) {
+ clearTimeout(inactivityTimerRef.current);
+ }
+
+ if (autoHideControls) {
+ const delayMs = (autoHideDelay ?? 3) * 1000;
+ inactivityTimerRef.current = setTimeout(() => {
+ setIsAutoHidden(true);
+ setControlsVisible(false);
+ }, delayMs);
+ }
+ }, [autoHideControls, autoHideDelay]);
+
+ useEffect(() => {
+ if (!autoHideControls) {
+ if (inactivityTimerRef.current) {
+ clearTimeout(inactivityTimerRef.current);
+ }
+ setIsAutoHidden(false);
+ return;
+ }
+
+ resetInactivityTimer();
+
+ const handleUserActivity = () => {
+ resetInactivityTimer();
+ };
+
+ const events = [
+ "pointerdown",
+ "mousedown",
+ "click",
+ "pointermove",
+ "mousemove",
+ "keydown",
+ "touchstart",
+ "touchend",
+ "touchmove",
+ "wheel",
+ ];
+
+ for (const ev of events) {
+ window.addEventListener(ev, handleUserActivity, { passive: true });
+ }
+
+ return () => {
+ if (inactivityTimerRef.current) {
+ clearTimeout(inactivityTimerRef.current);
+ }
+ for (const ev of events) {
+ window.removeEventListener(ev, handleUserActivity);
+ }
+ };
+ }, [autoHideControls, resetInactivityTimer]);
+
+ // Reset inactivity timer & zoom level whenever active media item changes
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset timer and zoom when active media item changes
+ useEffect(() => {
+ setZoom(1.0);
+ setPanOffset({ x: 0, y: 0 });
+ if (autoHideControls) {
+ resetInactivityTimer();
+ }
+ }, [item.id, autoHideControls, resetInactivityTimer]);
const toggleTagSelection = (tagId: number) => {
setSelectedTagIds((prev) => {
@@ -223,6 +596,11 @@ export default function Lightbox({
};
const handleTouchEnd = (e: React.TouchEvent) => {
+ if (isPinchingRef.current || isPannable) {
+ touchStartX.current = null;
+ touchStartY.current = null;
+ return;
+ }
if (touchStartX.current === null || touchStartY.current === null) return;
const touch = e.changedTouches[0];
const diffX = touch.clientX - touchStartX.current;
@@ -240,6 +618,27 @@ export default function Lightbox({
touchStartY.current = null;
};
+ const overlayRef = useRef(null);
+
+ // Prevent browser-level viewport zoom / pinch-zoom gestures while lightbox is active
+ useEffect(() => {
+ const container = overlayRef.current;
+ if (!container) return;
+
+ const preventBrowserZoom = (e: TouchEvent) => {
+ if (e.touches.length > 1) {
+ e.preventDefault();
+ }
+ };
+
+ container.addEventListener("touchmove", preventBrowserZoom, {
+ passive: false,
+ });
+ return () => {
+ container.removeEventListener("touchmove", preventBrowserZoom);
+ };
+ }, []);
+
// Lock body scroll on mount, restore on unmount
useEffect(() => {
const originalOverflow = document.body.style.overflow;
@@ -304,14 +703,33 @@ export default function Lightbox({
}
}
- // Keyboard navigation
+ // Keyboard navigation & shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- if (e.key === "ArrowRight" && onNext && !isPageLoading) onNext();
- if (e.key === "ArrowLeft" && onPrev) onPrev();
+ const activeEl = document.activeElement;
+ if (
+ activeEl &&
+ (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA")
+ ) {
+ return;
+ }
+
+ if (e.key === "h" || e.key === "H") {
+ e.preventDefault();
+ setControlsVisible((prev) => !prev);
+ } else if (e.key === "Escape") {
+ if (!controlsVisible) {
+ setControlsVisible(true);
+ } else {
+ onClose();
+ }
+ } else if (e.key === "ArrowRight" && onNext && !isPageLoading) {
+ onNext();
+ } else if (e.key === "ArrowLeft" && onPrev) {
+ onPrev();
+ }
},
- [onClose, onNext, onPrev, isPageLoading],
+ [onClose, onNext, onPrev, isPageLoading, controlsVisible],
);
useEffect(() => {
@@ -341,6 +759,7 @@ export default function Lightbox({
return (
{
- // If clicking the background (not the image), close?
- // Or maybe clicking image toggles sidebar?
- // Let's make clicking background close.
+ // If clicking the background (not the image), close
if (e.target === e.currentTarget) {
if (isRecentlyMounted) return;
onClose();
@@ -360,7 +777,6 @@ export default function Lightbox({
}}
onKeyDown={handleKeyActivate(() => {
if (typeof window !== "undefined") {
- // Check if we are actually on the target area, though handleKeyActivate handles the key
onClose();
}
})}
@@ -368,20 +784,40 @@ export default function Lightbox({
tabIndex={0}
aria-label="Close lightbox"
>
+
{
+ e.stopPropagation();
+ setControlsVisible((prev) => !prev);
+ }}
+ title={controlsVisible ? "Hide controls (H)" : "Show controls (H)"}
+ aria-label="Toggle controls"
+ >
+ {controlsVisible ? : }
+
+
{onPrev && (
{
e.stopPropagation();
onPrev();
}}
+ aria-label="Previous item"
+ title="Previous"
>
- ‹
+
)}
-
+
{item.mediaType === "video" ? (
// biome-ignore lint/a11y/useMediaCaption: User generated content does not have captions
{
- // Don't toggle info if clicking a link
+ // Don't toggle if clicking a link
if ((e.target as HTMLElement).tagName === "A") return;
e.stopPropagation();
- setShowInfo(!showInfo);
+ setShowInfo((prev) => !prev);
}}
onKeyDown={handleKeyActivate(() => {
- setShowInfo(!showInfo);
+ setShowInfo((prev) => !prev);
})}
role="button"
tabIndex={0}
@@ -412,15 +848,35 @@ export default function Lightbox({
) : (
{
e.stopPropagation();
if (isRecentlyMounted) return;
- if (typeof window !== "undefined" && window.innerWidth >= 768) {
- setShowInfo(!showInfo);
+ if (dragDistanceRef.current > 3) {
+ dragDistanceRef.current = 0;
+ return;
}
+ setShowInfo((prev) => !prev);
}}
width={1200}
height={800}
@@ -432,20 +888,86 @@ export default function Lightbox({
{onNext && (
{
e.stopPropagation();
if (isPageLoading) return;
onNext();
}}
disabled={isPageLoading}
+ aria-label="Next item"
title={isPageLoading ? "Loading next page..." : "Next"}
>
- {isPageLoading ? : "›"}
+ {isPageLoading ? (
+
+ ) : (
+
+ )}
)}
-
+
+ {/* Fit Mode Cycle */}
+ {
+ e.stopPropagation();
+ cycleFitMode();
+ }}
+ title={`Fit mode: ${currentFitMode}`}
+ aria-label="Cycle fit mode"
+ >
+ {currentFitMode === "fitBoth" ? (
+
+ ) : currentFitMode === "fitWidth" ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Zoom Controls */}
+ {
+ e.stopPropagation();
+ handleZoomOut();
+ }}
+ disabled={zoom <= minZoomVal}
+ title="Zoom Out"
+ aria-label="Zoom Out"
+ >
+
+
+ {
+ e.stopPropagation();
+ handleResetZoom();
+ }}
+ title="Reset Zoom (100%)"
+ aria-label="Reset Zoom"
+ >
+ {Math.round(zoom * 100)}%
+
+ {
+ e.stopPropagation();
+ handleZoomIn();
+ }}
+ disabled={zoom >= maxZoomVal}
+ title="Zoom In"
+ aria-label="Zoom In"
+ >
+
+
+
{onDelete && (
<>
{
await expect(lightbox).not.toBeVisible();
});
+test("lightbox navigation zones span full-height and handle navigation", async ({
+ page,
+}) => {
+ await page.goto("/gallery");
+ await expect(page.getByTestId("loading-skeleton")).toBeHidden();
+
+ try {
+ await expect(page.locator('img[class*="media"]').first()).toBeVisible({
+ timeout: 5000,
+ });
+ } catch {
+ test.skip(true, "No gallery items to test lightbox navigation zones");
+ return;
+ }
+
+ // Open lightbox on first item
+ await page.locator('img[class*="media"]').first().click();
+ const lightbox = page.locator('div[class*="overlay"]');
+ await expect(lightbox).toBeVisible();
+
+ // Next navigation zone should be visible and attached
+ const nextZone = page.locator('button[class*="navZoneNext"]').first();
+ const prevZone = page.locator('button[class*="navZonePrev"]').first();
+
+ await expect(nextZone).toBeVisible();
+
+ // Verify bounding box height matches mainArea
+ const boundingBox = await nextZone.boundingBox();
+ const mainAreaBox = await page
+ .locator('div[class*="mainArea"]')
+ .first()
+ .boundingBox();
+
+ expect(boundingBox).not.toBeNull();
+ expect(mainAreaBox).not.toBeNull();
+
+ if (boundingBox && mainAreaBox) {
+ expect(boundingBox.height).toBeGreaterThanOrEqual(mainAreaBox.height - 2);
+ }
+
+ // Click next zone
+ if (await nextZone.isVisible()) {
+ await nextZone.click();
+ // After navigating to second item, prev zone should become visible
+ await expect(prevZone).toBeVisible();
+ }
+
+ await page.keyboard.press("Escape");
+ await expect(lightbox).not.toBeVisible();
+});
+
+test("lightbox show/hide controls toggles, H key shortcut, and two-step Escape", async ({
+ page,
+}) => {
+ await page.goto("/gallery");
+ await expect(page.getByTestId("loading-skeleton")).toBeHidden();
+
+ try {
+ await expect(page.locator('img[class*="media"]').first()).toBeVisible({
+ timeout: 5000,
+ });
+ } catch {
+ test.skip(true, "No gallery items to test lightbox controls toggle");
+ return;
+ }
+
+ // Open lightbox
+ await page.locator('img[class*="media"]').first().click();
+ const lightbox = page.locator('div[class*="overlay"]');
+ await expect(lightbox).toBeVisible();
+
+ const toggleBtn = page.locator('button[class*="persistentToggleBtn"]');
+ await expect(toggleBtn).toBeVisible();
+
+ const controls = page.locator('div[class*="controls"]').first();
+ const sidebar = page.locator('div[class*="sidebar"]').first();
+
+ // Initially controls and sidebar are visible
+ await expect(controls).toHaveAttribute("data-visible", "true");
+ await expect(sidebar).not.toHaveClass(/sidebarHidden/);
+
+ // Click persistent toggle button -> controls hide, sidebar remains visible
+ await toggleBtn.click();
+ await expect(controls).toHaveAttribute("data-visible", "false");
+ await expect(sidebar).not.toHaveClass(/sidebarHidden/);
+
+ // Toggle button remains visible
+ await expect(toggleBtn).toBeVisible();
+
+ // Press H key -> controls reappear
+ await page.keyboard.press("h");
+ await expect(controls).toHaveAttribute("data-visible", "true");
+
+ // Hide controls again with H key
+ await page.keyboard.press("H");
+ await expect(controls).toHaveAttribute("data-visible", "false");
+
+ // Two-step Escape: First Escape restores controls, second Escape closes lightbox
+ await page.keyboard.press("Escape");
+ await expect(controls).toHaveAttribute("data-visible", "true");
+ await expect(lightbox).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(lightbox).not.toBeVisible();
+});
+
+test("lightbox fit mode cycling and zoom controls", async ({ page }) => {
+ await page.goto("/gallery");
+ await expect(page.getByTestId("loading-skeleton")).toBeHidden();
+
+ try {
+ await expect(page.locator('img[class*="media"]').first()).toBeVisible({
+ timeout: 5000,
+ });
+ } catch {
+ test.skip(true, "No gallery items to test lightbox fit mode and zoom");
+ return;
+ }
+
+ // Open lightbox
+ await page.locator('img[class*="media"]').first().click();
+ const lightbox = page.locator('div[class*="overlay"]');
+ await expect(lightbox).toBeVisible();
+
+ // Check Fit Mode cycle button
+ const fitBtn = page.locator('button[aria-label="Cycle fit mode"]').first();
+ await expect(fitBtn).toBeVisible();
+ await expect(fitBtn).toHaveAttribute("title", /Fit mode:.*/);
+
+ // Click to cycle fit modes
+ const initialTitle = await fitBtn.getAttribute("title");
+ await fitBtn.click();
+ const nextTitle = await fitBtn.getAttribute("title");
+ expect(nextTitle).not.toBe(initialTitle);
+
+ await fitBtn.click();
+ const thirdTitle = await fitBtn.getAttribute("title");
+ expect(thirdTitle).not.toBe(nextTitle);
+
+ // Zoom controls
+ const zoomInBtn = page.locator('button[aria-label="Zoom In"]').first();
+ const zoomOutBtn = page.locator('button[aria-label="Zoom Out"]').first();
+ const zoomResetBtn = page.locator('button[aria-label="Reset Zoom"]').first();
+
+ await expect(zoomInBtn).toBeVisible();
+ await expect(zoomOutBtn).toBeVisible();
+ await expect(zoomResetBtn).toBeVisible();
+
+ // Reset zoom to 100% first
+ await zoomResetBtn.click();
+ await expect(zoomResetBtn).toHaveText("100%");
+
+ // Click Zoom In
+ await zoomInBtn.click();
+ await expect(zoomResetBtn).toHaveText(/(125|150)%/);
+
+ // Click Zoom Reset
+ await zoomResetBtn.click();
+ await expect(zoomResetBtn).toHaveText("100%");
+
+ // Click Zoom Out
+ await zoomOutBtn.click();
+ await expect(zoomResetBtn).toHaveText(/(75|50)%/);
+
+ await page.keyboard.press("Escape");
+ await expect(lightbox).not.toBeVisible();
+});
+
+test("lightbox touch pinch-to-zoom gesture", async ({ page }) => {
+ await page.goto("/gallery");
+ await expect(page.getByTestId("loading-skeleton")).toBeHidden();
+
+ try {
+ await expect(page.locator('img[class*="media"]').first()).toBeVisible({
+ timeout: 5000,
+ });
+ } catch {
+ test.skip(true, "No gallery items to test lightbox pinch-to-zoom");
+ return;
+ }
+
+ // Open lightbox
+ await page.locator('img[class*="media"]').first().click();
+ const lightbox = page.locator('div[class*="overlay"]');
+ await expect(lightbox).toBeVisible();
+
+ const img = page.locator('img[class*="image"]').first();
+ await expect(img).toBeVisible();
+ const zoomResetBtn = page.locator('button[aria-label="Reset Zoom"]').first();
+
+ // Dispatch multi-touch pinch outward gesture on image
+ const box = await img.boundingBox();
+ if (box) {
+ const centerX = box.x + box.width / 2;
+ const centerY = box.y + box.height / 2;
+
+ await img.dispatchEvent("touchstart", {
+ touches: [
+ { identifier: 0, clientX: centerX - 10, clientY: centerY - 10 },
+ { identifier: 1, clientX: centerX + 10, clientY: centerY + 10 },
+ ],
+ });
+
+ await img.dispatchEvent("touchmove", {
+ touches: [
+ { identifier: 0, clientX: centerX - 50, clientY: centerY - 50 },
+ { identifier: 1, clientX: centerX + 50, clientY: centerY + 50 },
+ ],
+ });
+
+ await expect(zoomResetBtn).not.toHaveText("100%");
+
+ await img.dispatchEvent("touchend", {
+ touches: [],
+ changedTouches: [
+ { identifier: 0, clientX: centerX - 50, clientY: centerY - 50 },
+ { identifier: 1, clientX: centerX + 50, clientY: centerY + 50 },
+ ],
+ });
+ }
+
+ await page.keyboard.press("Escape");
+ await expect(lightbox).not.toBeVisible();
+});
+
test("gallery defaults to infinite scroll mode", async ({ page }) => {
await page.goto("/gallery");
await expect(page.getByTestId("loading-skeleton")).toBeHidden();
diff --git a/tests/settings.spec.ts b/tests/settings.spec.ts
index 6cf3014..d917b6a 100644
--- a/tests/settings.spec.ts
+++ b/tests/settings.spec.ts
@@ -319,4 +319,124 @@ test.describe
await expect(page.locator("#galleryPageSize").first()).toHaveValue("50");
await expect(page.locator("#loopVideos").first()).toBeChecked();
});
+
+ test("renders Lightbox settings section and handles auto-hide toggle state", async ({
+ page,
+ }) => {
+ // Lightbox heading should be visible under App Settings
+ await expect(
+ page.getByRole("heading", { name: "Lightbox Settings" }).first(),
+ ).toBeVisible();
+
+ // Check all controls exist with defaults
+ const fitModeSelect = page.locator("#lightboxFitMode").first();
+ const zoomMinInput = page.locator("#lightboxZoomMin").first();
+ const zoomMaxInput = page.locator("#lightboxZoomMax").first();
+ const zoomStepInput = page.locator("#lightboxZoomStep").first();
+ const autoHideToggle = page.locator("#lightboxAutoHideControls").first();
+ const autoHideDelayInput = page.locator("#lightboxAutoHideDelay").first();
+
+ await expect(fitModeSelect).toBeVisible();
+ await expect(fitModeSelect).toHaveValue("fitBoth");
+
+ await expect(zoomMinInput).toBeVisible();
+ await expect(zoomMinInput).toHaveValue("50");
+
+ await expect(zoomMaxInput).toBeVisible();
+ await expect(zoomMaxInput).toHaveValue("200");
+
+ await expect(zoomStepInput).toBeVisible();
+ await expect(zoomStepInput).toHaveValue("25");
+
+ await expect(autoHideToggle).toBeAttached();
+ expect(await autoHideToggle.isChecked()).toBe(false);
+
+ // Auto-hide delay input should be disabled when auto-hide is off
+ await expect(autoHideDelayInput).toBeDisabled();
+
+ // Toggle auto-hide on via switch label
+ const toggleLabel = page
+ .locator("label[for='lightboxAutoHideControls']")
+ .first();
+ await toggleLabel.click();
+
+ expect(await autoHideToggle.isChecked()).toBe(true);
+ await expect(autoHideDelayInput).toBeEnabled();
+ });
+
+ test("validates Lightbox zoom bounds and range rules", async ({ page }) => {
+ const zoomMinInput = page.locator("#lightboxZoomMin").first();
+ const zoomMaxInput = page.locator("#lightboxZoomMax").first();
+ const saveButton = page
+ .getByRole("button", { name: "Save Changes" })
+ .first();
+
+ // Min zoom out of range (< 10)
+ await zoomMinInput.fill("5");
+ await saveButton.click();
+ const alert = page.locator("[class*='notification']").first();
+ await expect(alert).toBeVisible({ timeout: 15000 });
+ await expect(alert).toContainText(
+ "Lightbox minimum zoom must be between 10% and 100%.",
+ );
+
+ // Reset min zoom, test min > max validation
+ await zoomMinInput.fill("800");
+ await saveButton.click();
+ await expect(alert).toBeVisible({ timeout: 15000 });
+ await expect(alert).toContainText(
+ "Lightbox minimum zoom cannot be greater than maximum zoom.",
+ );
+ });
+
+ test("can modify, save, and persist Lightbox settings", async ({
+ page,
+ }) => {
+ const fitModeSelect = page.locator("#lightboxFitMode").first();
+ const zoomMinInput = page.locator("#lightboxZoomMin").first();
+ const zoomMaxInput = page.locator("#lightboxZoomMax").first();
+ const zoomStepInput = page.locator("#lightboxZoomStep").first();
+ const autoHideToggleLabel = page
+ .locator("label[for='lightboxAutoHideControls']")
+ .first();
+ const autoHideDelayInput = page.locator("#lightboxAutoHideDelay").first();
+ const saveButton = page
+ .getByRole("button", { name: "Save Changes" })
+ .first();
+
+ await fitModeSelect.selectOption("fitWidth");
+ await zoomMinInput.fill("30");
+ await zoomMaxInput.fill("600");
+ await zoomStepInput.fill("50");
+ await autoHideToggleLabel.click();
+ await autoHideDelayInput.fill("5");
+
+ await saveButton.click();
+
+ const alert = page.locator("[class*='notification']").first();
+ await expect(alert).toBeVisible({ timeout: 15000 });
+ await expect(alert).toContainText(
+ "Settings saved and updated successfully!",
+ );
+
+ // Reload page and verify settings are preserved
+ await page.reload();
+ await expect(page.getByTestId("loading-skeleton")).toBeHidden();
+ await expect(
+ page.locator("[data-hydrated='true']").first(),
+ ).toBeVisible();
+
+ await expect(page.locator("#lightboxFitMode").first()).toHaveValue(
+ "fitWidth",
+ );
+ await expect(page.locator("#lightboxZoomMin").first()).toHaveValue("30");
+ await expect(page.locator("#lightboxZoomMax").first()).toHaveValue("600");
+ await expect(page.locator("#lightboxZoomStep").first()).toHaveValue("50");
+ expect(
+ await page.locator("#lightboxAutoHideControls").first().isChecked(),
+ ).toBe(true);
+ await expect(page.locator("#lightboxAutoHideDelay").first()).toHaveValue(
+ "5",
+ );
+ });
});
diff --git a/tests/unit/settings-defaults.test.ts b/tests/unit/settings-defaults.test.ts
new file mode 100644
index 0000000..ad69d0b
--- /dev/null
+++ b/tests/unit/settings-defaults.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_SETTINGS } from "@/types/settings";
+
+describe("Lightbox Settings Defaults", () => {
+ it("defines default values for all lightbox fields in DEFAULT_SETTINGS", () => {
+ expect(DEFAULT_SETTINGS.app.lightboxFitMode).toBe("fitBoth");
+ expect(DEFAULT_SETTINGS.app.lightboxZoomMin).toBe(50);
+ expect(DEFAULT_SETTINGS.app.lightboxZoomMax).toBe(200);
+ expect(DEFAULT_SETTINGS.app.lightboxZoomStep).toBe(25);
+ expect(DEFAULT_SETTINGS.app.lightboxAutoHideControls).toBe(false);
+ expect(DEFAULT_SETTINGS.app.lightboxAutoHideDelay).toBe(3);
+ });
+});