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 +

+ +
+
+ + +

+ Initial media sizing inside the lightbox container. +

+
+ +
+ + + handleAppChange( + "lightboxZoomMin", + parseInt(e.target.value, 10) || 25, + ) + } + className={styles.input} + required + /> +

+ Lower scale bound when zooming media in lightbox (10-100%). +

+
+ +
+ + + handleAppChange( + "lightboxZoomMax", + parseInt(e.target.value, 10) || 500, + ) + } + className={styles.input} + required + /> +

+ Upper scale bound when zooming media in lightbox + (100-1000%). +

+
+ +
+ + + 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( + "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" > + + {onPrev && ( )} -
+
{item.mediaType === "video" ? ( // biome-ignore lint/a11y/useMediaCaption: User generated content does not have captions
) : ( {row.post?.title { 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 && ( )} -
+
+ {/* Fit Mode Cycle */} + + + {/* Zoom Controls */} + + + + {onDelete && ( <>