diff --git a/README.md b/README.md index 6c920595..a4a2b907 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint) - `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views - `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top) +- `NEXT_PUBLIC_IMAGE_ACTIONS = 1` enables fullscreen and zoom actions when clicking on an image ## Alternate storage providers diff --git a/package.json b/package.json index 93fb2455..4f3cf89a 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "sonner": "^1.7.2", "swr": "^2.3.0", "ts-exif-parser": "^0.2.2", - "use-debounce": "^10.0.4" + "use-debounce": "^10.0.4", + "viewerjs": "^1.11.7" }, "devDependencies": { "@next/bundle-analyzer": "15.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7347f4c..890b8eb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: use-debounce: specifier: ^10.0.4 version: 10.0.4(react@19.0.0) + viewerjs: + specifier: ^1.11.7 + version: 1.11.7 devDependencies: '@next/bundle-analyzer': specifier: 15.1.6 @@ -4234,6 +4237,9 @@ packages: resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==} engines: {node: '>=10.12.0'} + viewerjs@1.11.7: + resolution: {integrity: sha512-0JuVqOmL5v1jmEAlG5EBDR3XquxY8DWFQbFMprOXgaBB0F7Q/X9xWdEaQc59D8xzwkdUgXEMSSknTpriq95igg==} + vue@3.4.27: resolution: {integrity: sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==} peerDependencies: @@ -9391,6 +9397,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + viewerjs@1.11.7: {} + vue@3.4.27(typescript@5.7.3): dependencies: '@vue/compiler-dom': 3.4.27 diff --git a/src/app/p/[photoId]/page.tsx b/src/app/p/[photoId]/page.tsx index a84aa724..86ec9013 100644 --- a/src/app/p/[photoId]/page.tsx +++ b/src/app/p/[photoId]/page.tsx @@ -77,6 +77,12 @@ export default async function PhotoPage({ if (!photo) { redirect(PATH_ROOT); } return ( - + ); } diff --git a/src/components/FullscreenButton.tsx b/src/components/FullscreenButton.tsx new file mode 100644 index 00000000..96a1a053 --- /dev/null +++ b/src/components/FullscreenButton.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useEffect, useCallback, RefObject } from 'react'; +import { MdFullscreen, MdFullscreenExit } from 'react-icons/md'; +import { clsx } from 'clsx/lite'; +import { useAppState } from '@/state/AppState'; +import LoaderButton from './primitives/LoaderButton'; + +export default function FullscreenButton({ + className, + imageRef, +}: { + className?: string; + imageRef: RefObject; +}) { + const { isFullscreen, setIsFullscreen, isCommandKOpen } = useAppState(); + + // Toggle fullscreen mode + const toggleFullscreen = useCallback(async () => { + if (!document.fullscreenElement) { + if (isCommandKOpen) return; + await imageRef.current?.requestFullscreen(); + setIsFullscreen?.(true); + } else { + await document.exitFullscreen(); + setIsFullscreen?.(false); + } + }, [imageRef, setIsFullscreen, isCommandKOpen]); + + // Toggle fullscreen on 'f' key press + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'f' || event.key === 'F') { + toggleFullscreen(); + } + }, [toggleFullscreen]); + + // Handle fullscreen change (e.g, switching tabs in fullscreen mode) + const handleFullscreenChange = useCallback(() => { + if (!document.fullscreenElement) { + setIsFullscreen?.(false); + } + }, [setIsFullscreen]); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('fullscreenchange', handleFullscreenChange); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('fullscreenchange', handleFullscreenChange); + }; + }, [handleKeyDown, handleFullscreenChange]); + + return ( + + : } + spinnerColor="light-gray" + styleAs="link" + onClick={toggleFullscreen} + /> + ); +} diff --git a/src/components/image/ImageActions.tsx b/src/components/image/ImageActions.tsx new file mode 100644 index 00000000..32ed935e --- /dev/null +++ b/src/components/image/ImageActions.tsx @@ -0,0 +1,68 @@ +import { useEffect, useRef } from 'react'; +import Viewer from 'viewerjs'; +import 'viewerjs/dist/viewer.css'; +import { clsx } from 'clsx/lite'; +import FullscreenButton from '../FullscreenButton'; + +export default function ImageActions({ + children, + enableImageActions = false, + className, +}: { + children: React.ReactNode; + enableImageActions?: boolean; + className?: string; +}) { + const containerRef = useRef(null); + const viewerRef = useRef(null); + + useEffect(() => { + if (containerRef.current && enableImageActions) { + viewerRef.current = new Viewer(containerRef.current, { + inline: false, + button: true, + navbar: false, + title: false, + toolbar: { + zoomIn: 1, + zoomOut: 1, + reset: 1, + tooltip: 1, + }, + }); + return () => { + viewerRef.current?.destroy(); + }; + } + }, [enableImageActions]); + + return ( + <> + +
+ {children} + {enableImageActions && ( + + )} +
+ + ); +} diff --git a/src/components/image/ImageLarge.tsx b/src/components/image/ImageLarge.tsx index 18569d92..339e9ba9 100644 --- a/src/components/image/ImageLarge.tsx +++ b/src/components/image/ImageLarge.tsx @@ -15,4 +15,4 @@ export default function ImageLarge(props: ImageProps) { height: Math.round(IMAGE_WIDTH_LARGE / aspectRatio), }} /> ); -}; +}; \ No newline at end of file diff --git a/src/photo/PhotoDetailPage.tsx b/src/photo/PhotoDetailPage.tsx index beb67cf4..aab19b82 100644 --- a/src/photo/PhotoDetailPage.tsx +++ b/src/photo/PhotoDetailPage.tsx @@ -11,6 +11,7 @@ import HiddenHeader from '@/tag/HiddenHeader'; import FocalLengthHeader from '@/focal/FocalLengthHeader'; import PhotoHeader from './PhotoHeader'; import { JSX } from 'react'; +import { IMAGE_ACTIONS_ENABLED } from '@/site/config'; export default function PhotoDetailPage({ photo, @@ -112,6 +113,7 @@ export default function PhotoDetailPage({ shouldShareSimulation={simulation !== undefined} shouldScrollOnShare={false} includeFavoriteInAdminMenu={includeFavoriteInAdminMenu} + enableImageActions={IMAGE_ACTIONS_ENABLED} />, ]} /> diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 59fc577a..b0648b27 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -36,6 +36,7 @@ import { useRef } from 'react'; import useOnVisible from '@/utility/useOnVisible'; import PhotoDate from './PhotoDate'; import { useAppState } from '@/state/AppState'; +import ImageActions from '@/components/image/ImageActions'; export default function PhotoLarge({ photo, @@ -56,6 +57,7 @@ export default function PhotoLarge({ shouldShareFocalLength, includeFavoriteInAdminMenu, onVisible, + enableImageActions = false, }: { photo: Photo className?: string @@ -76,6 +78,7 @@ export default function PhotoLarge({ shouldScrollOnShare?: boolean includeFavoriteInAdminMenu?: boolean onVisible?: () => void + enableImageActions?: boolean }) { const ref = useRef(null); @@ -143,17 +146,22 @@ export default function PhotoLarge({ arePhotosMatted && 'h-[90%]', arePhotosMatted && matteContentWidthForAspectRatio(), )}> - + + alt={altTextForPhoto(photo)} + src={photo.url} + aspectRatio={photo.aspectRatio} + blurDataURL={photo.blurData} + blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)} + priority={priority} + /> + } contentSide={ diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index 3b2ee2c9..29438d9d 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -78,6 +78,7 @@ export default function SiteChecklistClient({ isPublicApiEnabled, isPriorityOrderEnabled, isOgTextBottomAligned, + isImageActionsEnabled, // Misc baseUrl, commitSha, @@ -612,6 +613,15 @@ export default function SiteChecklistClient({ keep OG image text bottom aligned (default is {'"top"'}): {renderEnvVars(['NEXT_PUBLIC_OG_TEXT_ALIGNMENT'])} + + Set environment variable to {'"1"'} to enable fullscreen and zoom + actions when clicking on an image: + {renderEnvVars(['NEXT_PUBLIC_IMAGE_ACTIONS'])} + } diff --git a/src/site/config.ts b/src/site/config.ts index 70912080..249c59c4 100644 --- a/src/site/config.ts +++ b/src/site/config.ts @@ -205,6 +205,8 @@ export const PRIORITY_ORDER_ENABLED = process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1'; export const OG_TEXT_BOTTOM_ALIGNMENT = (process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM'; +export const IMAGE_ACTIONS_ENABLED = + process.env.NEXT_PUBLIC_IMAGE_ACTIONS === '1'; // INTERNAL @@ -281,6 +283,7 @@ export const CONFIG_CHECKLIST_STATUS = { isPublicApiEnabled: PUBLIC_API_ENABLED, isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED, isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT, + isImageActionsEnabled: IMAGE_ACTIONS_ENABLED, // MISC baseUrl: BASE_URL, commitSha: VERCEL_GIT_COMMIT_SHA_SHORT, diff --git a/src/state/AppState.ts b/src/state/AppState.ts index 69975e9e..9dcde3ca 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -39,6 +39,9 @@ export interface AppStateContext { setShouldDebugImageFallbacks?: Dispatch> shouldShowBaselineGrid?: boolean setShouldShowBaselineGrid?: Dispatch> + // FULLSCREEN + isFullscreen?: boolean + setIsFullscreen?: Dispatch> } export const AppStateContext = createContext({}); diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index e8619fec..0f04d631 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -52,6 +52,9 @@ export default function AppStateProvider({ useState(false); const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = useState(false); + // FULLSCREEN + const [isFullscreen, setIsFullscreen] = + useState(false); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); @@ -122,6 +125,9 @@ export default function AppStateProvider({ setShouldDebugImageFallbacks, shouldShowBaselineGrid, setShouldShowBaselineGrid, + // FULLSCREEN + isFullscreen, + setIsFullscreen, }} > {children}