From bdcf5bc3b4780913e0e93b5627068c30b8b4b294 Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Fri, 13 Mar 2026 13:20:00 +0200 Subject: [PATCH 1/3] Added a new ReaderModal component to replace the old Fancybox implementation, made changes to the GalleryCategoriesWithModalSlider component to manage modal state with React. --- frontend-next-migration/.gitignore | 2 + .../ui/GalleryCategoriesWithModalSlider.tsx | 245 +++++++++--------- .../src/entities/Gallery/ui/ReaderModal.tsx | 42 +++ 3 files changed, 173 insertions(+), 116 deletions(-) create mode 100644 frontend-next-migration/src/entities/Gallery/ui/ReaderModal.tsx diff --git a/frontend-next-migration/.gitignore b/frontend-next-migration/.gitignore index 7c6929bcf..9d0b100c4 100644 --- a/frontend-next-migration/.gitignore +++ b/frontend-next-migration/.gitignore @@ -27,6 +27,8 @@ yarn-error.log* # local env files .env*.local +.env +.env.* # vercel .vercel diff --git a/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx b/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx index 207b62b32..98746f53c 100644 --- a/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx +++ b/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx @@ -2,8 +2,8 @@ 'use client'; import Image from 'next/image'; import { memo, useEffect, useState } from 'react'; -import Fancybox from '@/shared/ui/Fancybox/Fancybox'; import { useClientTranslation } from '@/shared/i18n'; +import { ReaderModal } from '@/entities/Gallery/ui/ReaderModal'; import cls from './styles.module.scss'; export type GalleryCategoriesWithModalSliderProps = { @@ -19,9 +19,12 @@ export type GalleryCategoriesWithModalSliderProps = { export const GalleryCategoriesWithModalSlider = memo( ({ sources, cover }: GalleryCategoriesWithModalSliderProps) => { useClientTranslation('picture-galleries'); + + const [isModalOpen, setIsModalOpen] = useState(false); const [pageIndex, setPageIndex] = useState(0); const filteredSources = sources.filter((src) => src !== cover.url); + const sortedSources = [...filteredSources].sort((a, b) => { const numA = parseInt(a.match(/\d+/)?.[0] || '', 10); const numB = parseInt(b.match(/\d+/)?.[0] || '', 10); @@ -29,6 +32,7 @@ export const GalleryCategoriesWithModalSlider = memo( }); const allImages = [cover.url, ...sortedSources]; + const maxPageIndex = Math.ceil(sortedSources.length / 2); const changePage = (direction: 'next' | 'prev') => { @@ -39,30 +43,39 @@ export const GalleryCategoriesWithModalSlider = memo( useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'ArrowLeft' && pageIndex > 0) changePage('prev'); - else if (event.key === 'ArrowRight' && pageIndex < maxPageIndex) changePage('next'); + if (event.key === 'ArrowLeft' && pageIndex > 0) { + changePage('prev'); + } + + if (event.key === 'ArrowRight' && pageIndex < maxPageIndex) { + changePage('next'); + } + + if (event.key === 'Escape') { + setIsModalOpen(false); + } }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [pageIndex, maxPageIndex]); - const openCurrentInFancybox = () => { - const indexToOpen = pageIndex === 0 ? 0 : 1 + (pageIndex - 1) * 2; - const el = document.getElementById( - `fancybox-image-${indexToOpen}`, - ) as HTMLAnchorElement; - if (el) el.click(); + const openModal = () => { + setIsModalOpen(true); }; const renderImages = () => { return allImages.map((src, idx) => { let visible = false; + if (pageIndex === 0) { visible = idx === 0; } else { const start = (pageIndex - 1) * 2; const leftIdx = 1 + start; const rightIdx = 2 + start; + visible = idx === leftIdx || idx === rightIdx; } @@ -72,125 +85,125 @@ export const GalleryCategoriesWithModalSlider = memo( key={idx} style={{ display: visible ? 'flex' : 'none' }} > - - {idx - + {idx ); }); }; + return (
- +
+ {/* Zoom Button */}
- {/* Zoom Button */} -
- -
- - {/* Viewer */} -
- 0 ? () => changePage('prev') : undefined} - className={`${cls.navSymbol} ${pageIndex === 0 ? cls.disabled : ''}`} - role="button" - tabIndex={pageIndex > 0 ? 0 : -1} - aria-label="Previous" - > - {'<'} - - - {renderImages()} - - changePage('next') : undefined - } - className={`${cls.navSymbol} ${ - pageIndex === maxPageIndex ? cls.disabled : '' - }`} - role="button" - tabIndex={pageIndex < maxPageIndex ? 0 : -1} - aria-label="Next" - > - {'>'} - -
- - {/* Page slider (✅ stays close to cover) */} -
- - - {pageIndex * 2 || 1} - - setPageIndex(parseInt(event.target.value))} - className={cls.pageSlider} - style={ - { - '--percent': - maxPageIndex === 0 - ? '0%' - : `${(pageIndex / maxPageIndex) * 100}%`, - } as React.CSSProperties - } + Zoom + +
+ + {/* Viewer */} +
+ 0 ? () => changePage('prev') : undefined} + className={`${cls.navSymbol} ${pageIndex === 0 ? cls.disabled : ''}`} + role="button" + tabIndex={pageIndex > 0 ? 0 : -1} + aria-label="Previous" + > + {'<'} + - - {Math.min(sortedSources.length, (pageIndex || 0) * 2 + 1)} - - - -
+ {renderImages()} + + changePage('next') : undefined + } + className={`${cls.navSymbol} ${ + pageIndex === maxPageIndex ? cls.disabled : '' + }`} + role="button" + tabIndex={pageIndex < maxPageIndex ? 0 : -1} + aria-label="Next" + > + {'>'} + +
+ + {/* Slider */} +
+ + + {pageIndex * 2 || 1} + + setPageIndex(parseInt(event.target.value))} + className={cls.pageSlider} + style={ + { + '--percent': + maxPageIndex === 0 + ? '0%' + : `${(pageIndex / maxPageIndex) * 100}%`, + } as React.CSSProperties + } + /> + + + {Math.min(sortedSources.length, (pageIndex || 0) * 2 + 1)} + + +
- +
+ + {/* Modal */} + setIsModalOpen(false)} + > +
{renderImages()}
+
); }, diff --git a/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.tsx b/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.tsx new file mode 100644 index 000000000..9203a7a2c --- /dev/null +++ b/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.tsx @@ -0,0 +1,42 @@ +'use client'; +import { useEffect, useRef } from 'react'; + +type ReaderModalProps = { + open: boolean; + onClose: () => void; + children: React.ReactNode; +}; + +export function ReaderModal({ open, onClose, children }: ReaderModalProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + + if (!dialog) return; + + if (open && !dialog.open) { + dialog.showModal(); + } + + if (!open && dialog.open) { + dialog.close(); + } + }, [open]); + + return ( + + {children} + + ); +} From be9fe23e229b557e73951949a036964a99a0af02 Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Mon, 23 Mar 2026 14:49:26 +0200 Subject: [PATCH 2/3] Reader UI moved into modal (arrows, slider, page numbers), implemented desktop 2-page and mobile 1-page layouts, numbering now works correctly on mobile, image sizing improved in modal, touch swipe support for mobile added. --- .../ui/GalleryCategoriesWithModalSlider.tsx | 233 +++++++++++------- .../Gallery/ui/ReaderModal.module.scss | 27 ++ .../src/entities/Gallery/ui/ReaderModal.tsx | 48 ++-- .../entities/Gallery/ui/styles.module.scss | 6 + 4 files changed, 217 insertions(+), 97 deletions(-) create mode 100644 frontend-next-migration/src/entities/Gallery/ui/ReaderModal.module.scss diff --git a/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx b/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx index 98746f53c..b9bdb2435 100644 --- a/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx +++ b/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx @@ -1,7 +1,7 @@ /* eslint-disable complexity */ 'use client'; import Image from 'next/image'; -import { memo, useEffect, useState } from 'react'; +import { memo, useEffect, useState, useRef } from 'react'; import { useClientTranslation } from '@/shared/i18n'; import { ReaderModal } from '@/entities/Gallery/ui/ReaderModal'; import cls from './styles.module.scss'; @@ -22,6 +22,20 @@ export const GalleryCategoriesWithModalSlider = memo( const [isModalOpen, setIsModalOpen] = useState(false); const [pageIndex, setPageIndex] = useState(0); + const [isMobile, setIsMobile] = useState(false); + const touchStartX = useRef(0); + const touchEndX = useRef(0); + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth <= 768); + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + + return () => window.removeEventListener('resize', checkMobile); + }, []); const filteredSources = sources.filter((src) => src !== cover.url); @@ -33,7 +47,7 @@ export const GalleryCategoriesWithModalSlider = memo( const allImages = [cover.url, ...sortedSources]; - const maxPageIndex = Math.ceil(sortedSources.length / 2); + const maxPageIndex = isMobile ? sortedSources.length : Math.ceil(sortedSources.length / 2); const changePage = (direction: 'next' | 'prev') => { setPageIndex((prev) => @@ -50,10 +64,6 @@ export const GalleryCategoriesWithModalSlider = memo( if (event.key === 'ArrowRight' && pageIndex < maxPageIndex) { changePage('next'); } - - if (event.key === 'Escape') { - setIsModalOpen(false); - } }; window.addEventListener('keydown', handleKeyDown); @@ -61,16 +71,50 @@ export const GalleryCategoriesWithModalSlider = memo( return () => window.removeEventListener('keydown', handleKeyDown); }, [pageIndex, maxPageIndex]); + useEffect(() => { + if (isModalOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + }, [isModalOpen]); + const openModal = () => { setIsModalOpen(true); }; - const renderImages = () => { + const handleTouchStart = (e: React.TouchEvent) => { + touchStartX.current = e.touches[0].clientX; + }; + + const handleTouchMove = (e: React.TouchEvent) => { + touchEndX.current = e.touches[0].clientX; + }; + + const handleTouchEnd = () => { + if (!isMobile) return; + + const delta = touchStartX.current - touchEndX.current; + + if (Math.abs(delta) < 50) return; + + if (delta > 0 && pageIndex < maxPageIndex) { + changePage('next'); + } + + if (delta < 0 && pageIndex > 0) { + changePage('prev'); + } + }; + + const renderImages = (isModal = false) => { return allImages.map((src, idx) => { let visible = false; if (pageIndex === 0) { visible = idx === 0; + } else if (isMobile) { + visible = idx === pageIndex; } else { const start = (pageIndex - 1) * 2; const leftIdx = 1 + start; @@ -87,9 +131,9 @@ export const GalleryCategoriesWithModalSlider = memo( > {idx + {/* GALLERY (PREVIEW) */}
- {/* Zoom Button */}
- {/* Viewer */} -
- 0 ? () => changePage('prev') : undefined} - className={`${cls.navSymbol} ${pageIndex === 0 ? cls.disabled : ''}`} - role="button" - tabIndex={pageIndex > 0 ? 0 : -1} - aria-label="Previous" - > - {'<'} - - - {renderImages()} - - changePage('next') : undefined - } - className={`${cls.navSymbol} ${ - pageIndex === maxPageIndex ? cls.disabled : '' - }`} - role="button" - tabIndex={pageIndex < maxPageIndex ? 0 : -1} - aria-label="Next" - > - {'>'} - -
- - {/* Slider */} -
- - - {pageIndex * 2 || 1} - - setPageIndex(parseInt(event.target.value))} - className={cls.pageSlider} - style={ - { - '--percent': - maxPageIndex === 0 - ? '0%' - : `${(pageIndex / maxPageIndex) * 100}%`, - } as React.CSSProperties - } - /> - - - {Math.min(sortedSources.length, (pageIndex || 0) * 2 + 1)} - - - -
+
{renderImages(false)}
- {/* Modal */} + {/* MODAL (READER) */} setIsModalOpen(false)} > -
{renderImages()}
+
+ {/* Close */} +
+ +
+ + {/* Reader viewer */} +
+ 0 ? () => changePage('prev') : undefined} + className={`${cls.navSymbol} ${ + pageIndex === 0 ? cls.disabled : '' + }`} + > + {'<'} + + + {renderImages(true)} + + changePage('next') : undefined + } + className={`${cls.navSymbol} ${ + pageIndex === maxPageIndex ? cls.disabled : '' + }`} + > + {'>'} + +
+ + {/* Bottom control bar */} +
+ + + + {pageIndex === 0 + ? 1 + : isMobile + ? pageIndex + 1 + : (pageIndex - 1) * 2 + 2} + + + setPageIndex(parseInt(event.target.value))} + className={cls.pageSlider} + style={ + { + '--percent': + maxPageIndex === 0 + ? '0%' + : `${(pageIndex / maxPageIndex) * 100}%`, + } as React.CSSProperties + } + /> + + + {pageIndex === 0 + ? 1 + : isMobile + ? pageIndex + 1 + : Math.min(sortedSources.length, (pageIndex - 1) * 2 + 3)} + + + +
+
); diff --git a/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.module.scss b/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.module.scss new file mode 100644 index 000000000..61e38a8f7 --- /dev/null +++ b/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.module.scss @@ -0,0 +1,27 @@ +.dialog { + width: 100vw; + height: 100vh; + padding: 0; + border: none; + background: transparent; + position: fixed; +} + +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); +} + +.content { + position: relative; + z-index: 1; + + width: 100%; + height: 100%; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} \ No newline at end of file diff --git a/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.tsx b/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.tsx index 9203a7a2c..cd790a94a 100644 --- a/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.tsx +++ b/frontend-next-migration/src/entities/Gallery/ui/ReaderModal.tsx @@ -1,5 +1,6 @@ 'use client'; import { useEffect, useRef } from 'react'; +import cls from './ReaderModal.module.scss'; type ReaderModalProps = { open: boolean; @@ -12,31 +13,50 @@ export function ReaderModal({ open, onClose, children }: ReaderModalProps) { useEffect(() => { const dialog = dialogRef.current; - if (!dialog) return; - if (open && !dialog.open) { - dialog.showModal(); + if (open) { + if (!dialog.open) { + dialog.showModal(); + } + } else { + if (dialog.open) { + dialog.close(); + } } + }, [open]); - if (!open && dialog.open) { - dialog.close(); + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; } + + return () => { + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + }; }, [open]); + const handleClose = () => { + onClose(); + }; + return ( - {children} +
+ +
{children}
); } diff --git a/frontend-next-migration/src/entities/Gallery/ui/styles.module.scss b/frontend-next-migration/src/entities/Gallery/ui/styles.module.scss index 5f83d8dac..e3f74cd0a 100644 --- a/frontend-next-migration/src/entities/Gallery/ui/styles.module.scss +++ b/frontend-next-migration/src/entities/Gallery/ui/styles.module.scss @@ -220,3 +220,9 @@ margin-bottom: 0; } } + +.modalImage { + max-height: 80vh; + max-width: 90vw; + object-fit: contain; +} From 1721937350e5f13aa5a0f5a04da1dddc419589c0 Mon Sep 17 00:00:00 2001 From: EemeliJ Date: Thu, 2 Apr 2026 14:34:34 +0300 Subject: [PATCH 3/3] Made the reader more user friendly. The reader can now be opened by clicking on the comic, and closed by clicking on the backdrop. --- frontend-next-migration/.gitignore | 1 - .../ui/GalleryCategoriesWithModalSlider.tsx | 50 ++++++++----------- .../Gallery/ui/ReaderModal.module.scss | 28 ++++++----- .../src/entities/Gallery/ui/ReaderModal.tsx | 25 +++++----- .../entities/Gallery/ui/styles.module.scss | 5 -- 5 files changed, 47 insertions(+), 62 deletions(-) diff --git a/frontend-next-migration/.gitignore b/frontend-next-migration/.gitignore index 9d0b100c4..55ec8d184 100644 --- a/frontend-next-migration/.gitignore +++ b/frontend-next-migration/.gitignore @@ -28,7 +28,6 @@ yarn-error.log* # local env files .env*.local .env -.env.* # vercel .vercel diff --git a/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx b/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx index b9bdb2435..148f0ba72 100644 --- a/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx +++ b/frontend-next-migration/src/entities/Gallery/ui/GalleryCategoriesWithModalSlider.tsx @@ -23,6 +23,7 @@ export const GalleryCategoriesWithModalSlider = memo( const [isModalOpen, setIsModalOpen] = useState(false); const [pageIndex, setPageIndex] = useState(0); const [isMobile, setIsMobile] = useState(false); + const touchStartX = useRef(0); const touchEndX = useRef(0); @@ -67,7 +68,6 @@ export const GalleryCategoriesWithModalSlider = memo( }; window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); }, [pageIndex, maxPageIndex]); @@ -83,6 +83,7 @@ export const GalleryCategoriesWithModalSlider = memo( setIsModalOpen(true); }; + // SWIPE const handleTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; }; @@ -145,7 +146,7 @@ export const GalleryCategoriesWithModalSlider = memo( return (
- {/* GALLERY (PREVIEW) */} + {/* GALLERY */}
-
{renderImages(false)}
+
+ {renderImages(false)} +
- {/* MODAL (READER) */} + {/* MODAL */} setIsModalOpen(false)} >
- {/* Close */} -
- -
- - {/* Reader viewer */}
0 ? () => changePage('prev') : undefined} + onClick={(e) => { + e.stopPropagation(); + if (pageIndex > 0) changePage('prev'); + }} className={`${cls.navSymbol} ${ pageIndex === 0 ? cls.disabled : '' }`} @@ -207,9 +204,10 @@ export const GalleryCategoriesWithModalSlider = memo( {renderImages(true)} changePage('next') : undefined - } + onClick={(e) => { + e.stopPropagation(); + if (pageIndex < maxPageIndex) changePage('next'); + }} className={`${cls.navSymbol} ${ pageIndex === maxPageIndex ? cls.disabled : '' }`} @@ -218,7 +216,7 @@ export const GalleryCategoriesWithModalSlider = memo(
- {/* Bottom control bar */} + {/* Slider */}