From 977653403f757ab8823e216650c7bc6b0bcbabf0 Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 15:29:29 -0700 Subject: [PATCH 01/24] feat(settings): add lightbox settings types and default configuration --- src/types/settings.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/types/settings.ts b/src/types/settings.ts index 7bc669a..1f13813 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -14,6 +14,12 @@ export interface AppSettings { computeStorageStatistics: boolean; statisticsRankingLimit: number; implicitHierarchyFiltering: boolean; + lightboxFitMode: "fitBoth" | "fitWidth" | "fitHeight"; + lightboxZoomMin: number; + lightboxZoomMax: number; + lightboxZoomStep: number; + lightboxAutoHideControls: boolean; + lightboxAutoHideDelay: number; } export interface ScraperSettings { @@ -56,6 +62,12 @@ export const DEFAULT_SETTINGS: SystemSettings = { computeStorageStatistics: true, statisticsRankingLimit: 10, implicitHierarchyFiltering: true, + lightboxFitMode: "fitBoth", + lightboxZoomMin: 25, + lightboxZoomMax: 500, + lightboxZoomStep: 25, + lightboxAutoHideControls: false, + lightboxAutoHideDelay: 3, }, scraper: { rateLimit: "5M", From 85a091d6246a477547353a05b0b99749825bcfa8 Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 15:29:59 -0700 Subject: [PATCH 02/24] feat(settings): add lightbox section and validation to settings UI --- src/app/settings/page-client.tsx | 232 +++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/src/app/settings/page-client.tsx b/src/app/settings/page-client.tsx index 6bb1124..f4411fe 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 < 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.lightboxZoomMin > settings.app.lightboxZoomMax) { + setNotification({ + type: "error", + message: "Lightbox minimum zoom cannot be greater than maximum zoom.", + }); + 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 From 6e88d049e1e6130f6a6397561f7c3e4d7b62c92b Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 15:30:45 -0700 Subject: [PATCH 03/24] test(settings): add unit and e2e test coverage for lightbox settings --- tests/settings.spec.ts | 120 +++++++++++++++++++++++++++ tests/unit/settings-defaults.test.ts | 13 +++ 2 files changed, 133 insertions(+) create mode 100644 tests/unit/settings-defaults.test.ts diff --git a/tests/settings.spec.ts b/tests/settings.spec.ts index 6cf3014..ab3e233 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("25"); + + await expect(zoomMaxInput).toBeVisible(); + await expect(zoomMaxInput).toHaveValue("500"); + + 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..71d05d8 --- /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(25); + expect(DEFAULT_SETTINGS.app.lightboxZoomMax).toBe(500); + expect(DEFAULT_SETTINGS.app.lightboxZoomStep).toBe(25); + expect(DEFAULT_SETTINGS.app.lightboxAutoHideControls).toBe(false); + expect(DEFAULT_SETTINGS.app.lightboxAutoHideDelay).toBe(3); + }); +}); From 8704a864f84795dba8920afc40917f1158d49efb Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 15:40:34 -0700 Subject: [PATCH 04/24] fix(settings): prioritize min > max validation order in settings form --- src/app/settings/page-client.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/settings/page-client.tsx b/src/app/settings/page-client.tsx index f4411fe..e0aab82 100644 --- a/src/app/settings/page-client.tsx +++ b/src/app/settings/page-client.tsx @@ -160,6 +160,15 @@ 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 @@ -184,15 +193,6 @@ 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.lightboxZoomStep < 5 || settings.app.lightboxZoomStep > 100 From 9a02f2d24ed707958f52960981fef8953575558e Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 15:53:00 -0700 Subject: [PATCH 05/24] feat(lightbox): replace circular nav buttons with full-height navigation zones --- src/components/Lightbox.module.css | 65 +++++++++++++++--------------- src/components/Lightbox.tsx | 16 ++++++-- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/src/components/Lightbox.module.css b/src/components/Lightbox.module.css index e0e59b1..2c6ecd1 100644 --- a/src/components/Lightbox.module.css +++ b/src/components/Lightbox.module.css @@ -296,38 +296,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; +} + +.navZoneNext { + right: 0; } -.prevButton { - left: 20px; +.navZonePrev:hover { + background: linear-gradient(to right, hsl(0 0% 0% / 0.4), transparent); } -.nextButton { - right: 20px; +.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 +436,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..3625bc9 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,5 +1,6 @@ "use client"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -371,13 +372,15 @@ export default function Lightbox({ {onPrev && ( )} @@ -432,16 +435,21 @@ export default function Lightbox({ {onNext && ( )} From b303ba3f7aa38ebd3296eab110df4cc2f2c1c46b Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 15:53:59 -0700 Subject: [PATCH 06/24] test(lightbox): add e2e tests for full-height navigation zones --- tests/gallery.spec.ts | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/gallery.spec.ts b/tests/gallery.spec.ts index 8d9a4fb..e3f3892 100644 --- a/tests/gallery.spec.ts +++ b/tests/gallery.spec.ts @@ -51,6 +51,57 @@ test("gallery grid and lightbox", async ({ page }) => { 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("gallery defaults to infinite scroll mode", async ({ page }) => { await page.goto("/gallery"); await expect(page.getByTestId("loading-skeleton")).toBeHidden(); From 8cb266d87f8e0295cc4fba6d80380fadc9e92715 Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 16:10:45 -0700 Subject: [PATCH 07/24] feat(lightbox): pass auto-hide settings to page client components --- src/app/gallery/page-client.tsx | 12 ++++++++++++ src/app/gallery/page.tsx | 2 ++ src/app/timeline/page-client.tsx | 12 ++++++++++++ src/app/timeline/page.tsx | 2 ++ 4 files changed, 28 insertions(+) diff --git a/src/app/gallery/page-client.tsx b/src/app/gallery/page-client.tsx index 97881d2..7ac23cc 100644 --- a/src/app/gallery/page-client.tsx +++ b/src/app/gallery/page-client.tsx @@ -29,6 +29,8 @@ export default function GalleryPageClient({ loopVideos, autoplayVideos, muteAutoplayVideos, + autoHideControls, + autoHideDelay, playlistId, }: { initialItems: GalleryGroup[]; @@ -41,6 +43,8 @@ export default function GalleryPageClient({ loopVideos: boolean; autoplayVideos: boolean; muteAutoplayVideos: boolean; + autoHideControls?: boolean; + autoHideDelay?: number; playlistId?: number; }) { return ( @@ -55,6 +59,8 @@ export default function GalleryPageClient({ loopVideos={loopVideos} autoplayVideos={autoplayVideos} muteAutoplayVideos={muteAutoplayVideos} + autoHideControls={autoHideControls} + autoHideDelay={autoHideDelay} playlistId={playlistId} /> ); @@ -71,6 +77,8 @@ function GalleryPageContent({ loopVideos, autoplayVideos, muteAutoplayVideos, + autoHideControls, + autoHideDelay, playlistId, }: { initialItems: GalleryGroup[]; @@ -83,6 +91,8 @@ function GalleryPageContent({ loopVideos: boolean; autoplayVideos: boolean; muteAutoplayVideos: boolean; + autoHideControls?: boolean; + autoHideDelay?: number; playlistId?: number; }) { const [columnCount, setColumnCount] = useState(4); @@ -369,6 +379,8 @@ function GalleryPageContent({ onRefetch={handleRefetchSingle} loopVideos={loopVideos} isPageLoading={isPageLoading} + autoHideControls={autoHideControls} + autoHideDelay={autoHideDelay} onTagClick={(tagName) => { setSearchQuery(`tag:${tagName} `); closeLightbox(); diff --git a/src/app/gallery/page.tsx b/src/app/gallery/page.tsx index 65a4da9..462cc36 100644 --- a/src/app/gallery/page.tsx +++ b/src/app/gallery/page.tsx @@ -47,6 +47,8 @@ export default async function GalleryPage({ loopVideos={settings.loopVideos} autoplayVideos={settings.autoplayVideos ?? false} muteAutoplayVideos={settings.muteAutoplayVideos ?? true} + autoHideControls={settings.lightboxAutoHideControls ?? false} + autoHideDelay={settings.lightboxAutoHideDelay ?? 3} playlistId={ playlistId && !Number.isNaN(playlistId) ? playlistId : undefined } diff --git a/src/app/timeline/page-client.tsx b/src/app/timeline/page-client.tsx index 7f9ecea..bb19893 100644 --- a/src/app/timeline/page-client.tsx +++ b/src/app/timeline/page-client.tsx @@ -30,6 +30,8 @@ export default function TimelinePageClient({ muteAutoplayVideos, condensePostText, condensePostLines, + autoHideControls, + autoHideDelay, }: { initialPosts: TimelinePost[]; initialNextCursor: string | null; @@ -43,6 +45,8 @@ export default function TimelinePageClient({ muteAutoplayVideos: boolean; condensePostText: boolean; condensePostLines: number; + autoHideControls?: boolean; + autoHideDelay?: number; }) { return ( ); } @@ -75,6 +81,8 @@ function TimelinePageContent({ muteAutoplayVideos, condensePostText, condensePostLines, + autoHideControls, + autoHideDelay, }: { initialPosts: TimelinePost[]; initialNextCursor: string | null; @@ -88,6 +96,8 @@ function TimelinePageContent({ muteAutoplayVideos: boolean; condensePostText: boolean; condensePostLines: number; + autoHideControls?: boolean; + autoHideDelay?: number; }) { const [suppressSearch, setSuppressSearch] = useState(false); @@ -311,6 +321,8 @@ function TimelinePageContent({ onDelete={undefined} loopVideos={loopVideos} isPageLoading={isPageLoading} + autoHideControls={autoHideControls} + autoHideDelay={autoHideDelay} /> )} diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx index 65de819..d523914 100644 --- a/src/app/timeline/page.tsx +++ b/src/app/timeline/page.tsx @@ -45,6 +45,8 @@ export default async function TimelinePage({ muteAutoplayVideos={muteAutoplayVideos} condensePostText={settings.condensePostText ?? true} condensePostLines={settings.condensePostLines ?? 2} + autoHideControls={settings.lightboxAutoHideControls ?? false} + autoHideDelay={settings.lightboxAutoHideDelay ?? 3} /> ); } From 12589421692e45518dae97b8268310da4837e249 Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 16:10:47 -0700 Subject: [PATCH 08/24] feat(lightbox): implement show/hide controls framework with persistent toggle and auto-hide --- src/components/Lightbox.module.css | 47 ++++++++++++ src/components/Lightbox.tsx | 118 ++++++++++++++++++++++++----- 2 files changed, 147 insertions(+), 18 deletions(-) diff --git a/src/components/Lightbox.module.css b/src/components/Lightbox.module.css index 2c6ecd1..7487760 100644 --- a/src/components/Lightbox.module.css +++ b/src/components/Lightbox.module.css @@ -243,6 +243,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 +282,21 @@ display: flex; gap: 10px; z-index: 10; + 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 { diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 3625bc9..c5f71b3 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronLeft, ChevronRight, Eye, EyeOff } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -41,6 +41,8 @@ interface LightboxProps { onRefetch?: (postId: number) => Promise; loopVideos?: boolean; isPageLoading?: boolean; + autoHideControls?: boolean; + autoHideDelay?: number; onTagClick?: (tagName: string) => void; onUserClick?: (userName: string) => void; } @@ -60,11 +62,15 @@ export default function Lightbox({ onRefetch, loopVideos, isPageLoading = false, + autoHideControls = false, + autoHideDelay = 3, onTagClick, onUserClick, }: LightboxProps) { const { item } = row; const [showInfo, setShowInfo] = useState(true); + const [controlsVisible, setControlsVisible] = useState(true); + const [isAutoHidden, setIsAutoHidden] = useState(false); const [postTags, setPostTags] = useState([]); const [isAddingTag, setIsAddingTag] = useState(false); const [isRecentlyMounted, setIsRecentlyMounted] = useState(true); @@ -75,6 +81,53 @@ export default function Lightbox({ [], ); const router = useRouter(); + const inactivityTimerRef = useRef(null); + + 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(); + }; + + window.addEventListener("mousemove", handleUserActivity); + window.addEventListener("keydown", handleUserActivity); + window.addEventListener("touchstart", handleUserActivity); + + return () => { + if (inactivityTimerRef.current) { + clearTimeout(inactivityTimerRef.current); + } + window.removeEventListener("mousemove", handleUserActivity); + window.removeEventListener("keydown", handleUserActivity); + window.removeEventListener("touchstart", handleUserActivity); + }; + }, [autoHideControls, resetInactivityTimer]); const toggleTagSelection = (tagId: number) => { setSelectedTagIds((prev) => { @@ -305,14 +358,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(() => { @@ -351,9 +423,7 @@ export default function Lightbox({
{ - // 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(); @@ -361,7 +431,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(); } })} @@ -369,10 +438,24 @@ export default function Lightbox({ tabIndex={0} aria-label="Close lightbox" > + + {onPrev && ( )} -
+
{onDelete && ( <>
diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx index d523914..694e871 100644 --- a/src/app/timeline/page.tsx +++ b/src/app/timeline/page.tsx @@ -47,6 +47,10 @@ export default async function TimelinePage({ condensePostLines={settings.condensePostLines ?? 2} autoHideControls={settings.lightboxAutoHideControls ?? false} autoHideDelay={settings.lightboxAutoHideDelay ?? 3} + lightboxFitMode={settings.lightboxFitMode ?? "fitBoth"} + lightboxZoomMin={settings.lightboxZoomMin ?? 25} + lightboxZoomMax={settings.lightboxZoomMax ?? 500} + lightboxZoomStep={settings.lightboxZoomStep ?? 25} /> ); } From fe6531f29317ea42120720a3b63705e854b99bff Mon Sep 17 00:00:00 2001 From: darkkal Date: Wed, 22 Jul 2026 21:15:54 -0700 Subject: [PATCH 13/24] feat(lightbox): implement fit modes, zoom controls, and mouse/touch drag panning --- src/components/Lightbox.module.css | 45 ++++++- src/components/Lightbox.tsx | 182 ++++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 6 deletions(-) diff --git a/src/components/Lightbox.module.css b/src/components/Lightbox.module.css index 7487760..49b11fc 100644 --- a/src/components/Lightbox.module.css +++ b/src/components/Lightbox.module.css @@ -47,8 +47,40 @@ 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; +} + +.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 { @@ -320,6 +352,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; diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 46e748e..c328398 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,6 +1,16 @@ "use client"; -import { ChevronLeft, ChevronRight, Eye, EyeOff } from "lucide-react"; +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"; @@ -43,6 +53,10 @@ interface LightboxProps { isPageLoading?: boolean; autoHideControls?: boolean; autoHideDelay?: number; + fitMode?: "fitBoth" | "fitWidth" | "fitHeight"; + zoomMin?: number; + zoomMax?: number; + zoomStep?: number; onTagClick?: (tagName: string) => void; onUserClick?: (userName: string) => void; } @@ -64,6 +78,10 @@ export default function Lightbox({ isPageLoading = false, autoHideControls = false, autoHideDelay = 3, + fitMode = "fitBoth", + zoomMin = 25, + zoomMax = 500, + zoomStep = 25, onTagClick, onUserClick, }: LightboxProps) { @@ -71,6 +89,14 @@ export default function Lightbox({ 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); @@ -83,6 +109,76 @@ 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 cycleFitMode = () => { + 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 clamped = Math.min(maxZoomVal, Math.max(minZoomVal, next)); + if (clamped <= 1.0) { + setPanOffset({ x: 0, y: 0 }); + } + return clamped; + }); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (zoom <= 1.0) return; + e.preventDefault(); + setIsDragging(true); + dragStartRef.current = { + x: e.clientX - panOffset.x, + y: e.clientY - panOffset.y, + }; + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging || !dragStartRef.current || zoom <= 1.0) return; + e.preventDefault(); + setPanOffset({ + x: e.clientX - dragStartRef.current.x, + y: e.clientY - dragStartRef.current.y, + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + dragStartRef.current = null; + }; + const resetInactivityTimer = useCallback(() => { setIsAutoHidden(false); setControlsVisible(true); @@ -142,9 +238,11 @@ export default function Lightbox({ }; }, [autoHideControls, resetInactivityTimer]); - // Reset inactivity timer whenever active media item changes - // biome-ignore lint/correctness/useExhaustiveDependencies: reset timer when active media item changes + // 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(); } @@ -488,7 +586,7 @@ export default function Lightbox({ )} -
+
{item.mediaType === "video" ? ( // biome-ignore lint/a11y/useMediaCaption: User generated content does not have captions