From 9a3953ab135644e8d4204149389e48e82242279c Mon Sep 17 00:00:00 2001 From: Aleksandar Petkov Date: Sun, 3 Dec 2023 19:03:13 +0200 Subject: [PATCH] Implement fullscreen image slider when image is clicked (#1676) Currently we don't have a gallery component, and when image is uploaded with role==gallery those images remain unusable on larger screens, due to their small size This commit makes it so a full-screen gallery like is opened whenever an image with role==gallery is clicked. --- src/common/util/campaignImageUrls.ts | 27 ++- src/common/util/newsFilesUrls.ts | 4 +- .../client/campaign-news/CampaignNewsList.tsx | 30 ++- .../campaign-news/SingleArticlePage.tsx | 2 +- .../client/campaigns/CampaignDetails.tsx | 4 +- .../client/campaigns/CampaignNewsSection.tsx | 26 ++- .../client/campaigns/CampaignSlider.tsx | 117 ---------- .../client/campaigns/StatisticsPage.tsx | 9 +- src/components/common/CampaignImageSlider.tsx | 216 ++++++++++++++++++ src/components/common/campaign-file/roles.ts | 6 + .../common/withFullScreenSlider.tsx | 49 ++++ src/gql/campaigns.ts | 2 +- 12 files changed, 333 insertions(+), 159 deletions(-) delete mode 100644 src/components/client/campaigns/CampaignSlider.tsx create mode 100644 src/components/common/CampaignImageSlider.tsx create mode 100644 src/components/common/withFullScreenSlider.tsx diff --git a/src/common/util/campaignImageUrls.ts b/src/common/util/campaignImageUrls.ts index 33f061812..829bb1b77 100644 --- a/src/common/util/campaignImageUrls.ts +++ b/src/common/util/campaignImageUrls.ts @@ -1,6 +1,6 @@ import getConfig from 'next/config' import { CampaignFile, CampaignResponse } from 'gql/campaigns' -import { CampaignFileRole } from 'components/common/campaign-file/roles' +import { CampaignFileRole, ImageSlider } from 'components/common/campaign-file/roles' const { publicRuntimeConfig } = getConfig() @@ -18,16 +18,21 @@ function findFileWithRole(campaign: CampaignResponse, role: CampaignFileRole) { /** * Finds all files with given role */ -function filterFilesWithRole(campaign: CampaignResponse, role: CampaignFileRole) { - return campaign?.campaignFiles?.filter((file) => file.role == role) -} - -export function campaignSliderUrls(campaign: CampaignResponse): string[] { - const files = filterFilesWithRole(campaign, CampaignFileRole.campaignPhoto) - if (files && files.length > 0) { - return files.map((file) => fileUrl(file)) - } - return [] +function filterFilesWithRole(campaign: CampaignResponse, role: CampaignFileRole[]) { + return campaign.campaignFiles.filter((file) => role.includes(file.role)) +} + +export function campaignSliderUrls(campaign: CampaignResponse): ImageSlider[] { + const sliderImageRoles = [CampaignFileRole.campaignPhoto, CampaignFileRole.gallery] + const files = filterFilesWithRole(campaign, sliderImageRoles) + const fileExtensionRemoverRegex = /.\w*$/ + return files.map((file) => { + return { + id: file.id, + src: `${publicRuntimeConfig.API_URL}/campaign-file/${file.id}`, + fileName: file.filename.replace(fileExtensionRemoverRegex, ''), + } + }) } export function campaignListPictureUrl(campaign: CampaignResponse): string { diff --git a/src/common/util/newsFilesUrls.ts b/src/common/util/newsFilesUrls.ts index 2fbedae55..b5cbed165 100644 --- a/src/common/util/newsFilesUrls.ts +++ b/src/common/util/newsFilesUrls.ts @@ -20,12 +20,14 @@ export function GetArticleDocuments(files: CampaignNewsFile[]) { } export function GetArticleGalleryPhotos(files: CampaignNewsFile[]) { + const fileExtensionRemoverRegex = /.\w*$/ return files .filter((file) => file.role === CampaignFileRole.gallery) .map((file) => { return { id: file.id, - imgSource: `${publicRuntimeConfig.API_URL}/campaign-news-file/${file.id}`, + src: `${publicRuntimeConfig.API_URL}/campaign-news-file/${file.id}`, + fileName: file.filename.replace(fileExtensionRemoverRegex, ''), } }) } diff --git a/src/components/client/campaign-news/CampaignNewsList.tsx b/src/components/client/campaign-news/CampaignNewsList.tsx index 63b7765f0..3ed1f36cd 100644 --- a/src/components/client/campaign-news/CampaignNewsList.tsx +++ b/src/components/client/campaign-news/CampaignNewsList.tsx @@ -17,6 +17,8 @@ import { QuillStypeWrapper } from 'components/common/QuillStyleWrapper' import { scrollToTop } from './utils/scrollToTop' import { getArticleHeight } from './utils/getArticleHeight' +import withFullScreenSlider from 'components/common/withFullScreenSlider' + const PREFIX = 'CampaignNewsSection' const classes = { defaultPadding: `${PREFIX}-defaultPadding`, @@ -81,7 +83,7 @@ export default function CampaignNewsList({ articles }: Props) { const { t, i18n } = useTranslation('news') const INITIAL_HEIGHT_LIMIT = 400 const [isExpanded, expandContent] = useShowMoreContent() - + const WithFullScreenSlider = withFullScreenSlider(Image) return ( <> {articles?.map((article, index: number) => { @@ -154,13 +156,25 @@ export default function CampaignNewsList({ articles }: Props) { {article.newsFiles.length > 0 && ( - - {images.map((file) => ( - - {file.id} - - ))} - + <> + + {images.map((file, index) => { + return ( + + + + ) + })} + + )} {getArticleHeight(article.id) > INITIAL_HEIGHT_LIMIT && ( diff --git a/src/components/client/campaign-news/SingleArticlePage.tsx b/src/components/client/campaign-news/SingleArticlePage.tsx index 6a6e904ac..287d89e05 100644 --- a/src/components/client/campaign-news/SingleArticlePage.tsx +++ b/src/components/client/campaign-news/SingleArticlePage.tsx @@ -143,7 +143,7 @@ export default function SingleArticlePage({ slug }: Props) { {images.map((file) => ( - {file.id} + {file.id} ))} diff --git a/src/components/client/campaigns/CampaignDetails.tsx b/src/components/client/campaigns/CampaignDetails.tsx index 589038489..0c6987e4e 100644 --- a/src/components/client/campaigns/CampaignDetails.tsx +++ b/src/components/client/campaigns/CampaignDetails.tsx @@ -12,7 +12,7 @@ import SecurityIcon from '@mui/icons-material/Security' import { styled } from '@mui/material/styles' import DonationWishes from './DonationWishes' -import CampaignSlider from './CampaignSlider' +import CampaignImageSlider from 'components/common/CampaignImageSlider' import CampaignInfo from './CampaignInfo/CampaignInfo' import CampaignInfoGraphics from './CampaignInfoGraphics' import CampaignInfoOperator from './CampaignInfoOperator' @@ -128,7 +128,7 @@ export default function CampaignDetails({ campaign }: Props) { - + diff --git a/src/components/client/campaigns/CampaignNewsSection.tsx b/src/components/client/campaigns/CampaignNewsSection.tsx index 4f1739629..43c1c2976 100644 --- a/src/components/client/campaigns/CampaignNewsSection.tsx +++ b/src/components/client/campaigns/CampaignNewsSection.tsx @@ -28,6 +28,7 @@ import { sanitizeHTML } from 'common/util/htmlUtils' import { QuillStypeWrapper } from 'components/common/QuillStyleWrapper' import { scrollToTop } from '../campaign-news/utils/scrollToTop' import { getArticleHeight } from '../campaign-news/utils/getArticleHeight' +import withFullScreenSlider from 'components/common/withFullScreenSlider' const PREFIX = 'NewsTimeline' @@ -160,6 +161,7 @@ type Props = { export default function CampaignNewsSection({ campaign, canCreateArticle }: Props) { const { t, i18n } = useTranslation('news') const { small }: { small: boolean } = useMobile() + const WithFullScreenSlider = withFullScreenSlider(Image) const INITIAL_HEIGHT_LIMIT = 200 const [isExpanded, expandContent] = useShowMoreContent() @@ -297,16 +299,20 @@ export default function CampaignNewsSection({ campaign, canCreateArticle }: Prop ))} - {images.map((file) => ( - - {file.id} - - ))} + {images.map((file, index) => { + return ( + + + + ) + })} )} diff --git a/src/components/client/campaigns/CampaignSlider.tsx b/src/components/client/campaigns/CampaignSlider.tsx deleted file mode 100644 index a1a2cb823..000000000 --- a/src/components/client/campaigns/CampaignSlider.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react' -import Slider from 'react-slick' -import 'slick-carousel/slick/slick.css' -import 'slick-carousel/slick/slick-theme.css' -import Image from 'next/image' -import { styled } from '@mui/material/styles' -import { Typography } from '@mui/material' -import { useTranslation } from 'next-i18next' -import theme from 'common/theme' - -const classes = { - container: 'container', - slider: 'slider', -} - -const Root = styled('div')(() => ({ - [`& .${classes.container}`]: { - margin: theme.spacing(5, 0), - }, - [`& .${classes.slider}`]: { - '& .slick-slide': { - width: '100%', - height: '100%', - padding: theme.spacing(0, 1), - }, - '& .slick-prev': { - left: theme.spacing(6), - zIndex: 3, - }, - '& .slick-next': { - right: theme.spacing(6), - zIndex: 3, - }, - '& .slick-next::before, .slick-prev::before': { - fontSize: theme.spacing(5), - fontWeight: 'bold', - [theme.breakpoints.down('lg')]: { - fontSize: theme.spacing(4), - }, - [theme.breakpoints.down('md')]: { - fontSize: theme.spacing(3), - }, - }, - '& .slick-dots li button:before': { - fontSize: theme.spacing(1), - color: theme.palette.primary.main, - }, - }, -})) - -const settings = { - dots: true, - infinite: true, - speed: 500, - slidesToShow: 2, - responsive: [ - { - breakpoint: 450, - settings: { - slidesToShow: 1, - slidesToScroll: 3, - infinite: true, - dots: true, - speed: 500, - }, - }, - ], -} - -type Props = { - sliderImages: Array -} - -export default function CampaignSlider({ sliderImages }: Props) { - const { t } = useTranslation() - - if (sliderImages.length === 0) { - return null - } - if (sliderImages.length === 1) { - return ( - -
- {/* A11Y TODO: Add proper alt text*/} - campaign -
-
- ) - } - return ( - - - {t('campaigns:campaign.gallery')} - - - {sliderImages.map((image, index) => ( -
- {/* A11Y TODO: Add proper alt text*/} - campaign -
- ))} -
-
- ) -} diff --git a/src/components/client/campaigns/StatisticsPage.tsx b/src/components/client/campaigns/StatisticsPage.tsx index 1ed58a902..98a4e560b 100644 --- a/src/components/client/campaigns/StatisticsPage.tsx +++ b/src/components/client/campaigns/StatisticsPage.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'next-i18next' import { useViewCampaign } from 'common/hooks/campaigns' import Layout from 'components/client/layout/Layout' -import CenteredSpinner from 'components/common/CenteredSpinner' import NotFoundPage from 'pages/404' import CumulativeDonationsChart from './CampaignStatistics/CumulativeDonationsChart' import GroupedDonationsChart from './CampaignStatistics/GroupedDonationsChart' @@ -19,13 +18,7 @@ type Props = { slug: string } export default function StatisticsPage({ slug }: Props) { const { t } = useTranslation() const { data: campaignResponse, isLoading, isError } = useViewCampaign(slug) - if (isLoading || !campaignResponse) - return ( - <> - {/* {isLoading && } */} - {isError && } - - ) + if (isLoading || !campaignResponse) return <>{isError && } const campaign = campaignResponse.campaign const campaignTitle = campaign.title diff --git a/src/components/common/CampaignImageSlider.tsx b/src/components/common/CampaignImageSlider.tsx new file mode 100644 index 000000000..d4dd9ea1a --- /dev/null +++ b/src/components/common/CampaignImageSlider.tsx @@ -0,0 +1,216 @@ +import { useRef } from 'react' +import Slider, { Settings } from 'react-slick' +import 'slick-carousel/slick/slick.css' +import 'slick-carousel/slick/slick-theme.css' +import Image from 'next/image' +import { styled } from '@mui/material/styles' +import { Modal, Typography } from '@mui/material' +import { useTranslation } from 'next-i18next' +import theme from 'common/theme' +import { ImageSlider } from 'components/common/campaign-file/roles' +import withFullScreenSlider from './withFullScreenSlider' + +const classes = { + container: 'container', + slider: 'slider', + carouselFullScreen: 'carouselFullScreen', +} + +const Root = styled('div')(() => ({ + [`& .${classes.container}`]: { + margin: theme.spacing(5, 0), + }, + [`& .${classes.slider}`]: { + '& .slick-slide': { + width: '100%', + height: '100%', + }, + '& .slick-prev': { + left: theme.spacing(1), + zIndex: 3, + }, + '& .slick-next': { + right: theme.spacing(3.5), + zIndex: 3, + }, + '& .slick-next::before, .slick-prev::before': { + fontSize: theme.spacing(5), + fontWeight: 'bold', + [theme.breakpoints.down('lg')]: { + fontSize: theme.spacing(4), + }, + [theme.breakpoints.down('md')]: { + fontSize: theme.spacing(3), + }, + }, + '& .slick-dots li button:before': { + fontSize: theme.spacing(1), + color: theme.palette.primary.main, + }, + }, + [`& .${classes.carouselFullScreen}`]: { + maxWidth: '100%', + maxHeight: '100%', + '& .slick-prev': { + left: theme.spacing(1), + zIndex: 3, + }, + '& .slick-slide': { + position: 'relative', + }, + + '& .slick-next': { + right: theme.spacing(3.5), + zIndex: 3, + }, + + '& .slick-track img': { + maxWidth: '100%', + objectFit: 'contain', + }, + + '& .slick-track': { + aspectRatio: 20, + minHeight: 350, + [theme.breakpoints.up(600)]: { + aspectRatio: 45, + }, + [theme.breakpoints.up(800)]: { + minHeight: 370, + aspectRatio: 49, + }, + [theme.breakpoints.up(1024)]: { + minHeight: 540, + aspectRatio: 34, + }, + }, + + '& .slick-next::before, .slick-prev::before': { + fontSize: theme.spacing(5), + fontWeight: 'bold', + [theme.breakpoints.down('lg')]: { + fontSize: theme.spacing(4), + }, + [theme.breakpoints.down('md')]: { + fontSize: theme.spacing(3), + }, + }, + '& .slick-dots li button:before': { + fontSize: theme.spacing(1), + color: theme.palette.primary.main, + }, + }, +})) + +type Props = { + sliderImages: ImageSlider[] +} +const settings: Settings = { + dots: true, + infinite: false, + speed: 500, + slidesToShow: 2, + responsive: [ + { + breakpoint: 450, + settings: { + slidesToShow: 1, + slidesToScroll: 1, + dots: true, + speed: 500, + }, + }, + ], +} + +export default function CampaignImageSlider({ sliderImages }: Props) { + const { t } = useTranslation() + const WithFullScreenSlider = withFullScreenSlider(Image) + if (sliderImages.length === 0) { + return null + } + + if (sliderImages.length === 1) { + return ( + +
+ {sliderImages[0].fileName} +
+
+ ) + } + return ( + + + {t('campaigns:campaign.gallery')} + + + {sliderImages.map((image, index) => ( +
+ +
+ ))} +
+
+ ) +} + +type ModalProps = Props & { + onOpen: boolean + onClose: () => void + initialImage: React.MutableRefObject +} +export const FullScreenImageSlider = ({ + sliderImages, + onOpen, + onClose, + initialImage, +}: ModalProps) => { + const sliderRef = useRef(null) + const newSettings: Settings = { + ...settings, + slidesToShow: 1, + slidesToScroll: 1, + infinite: true, + beforeChange(currentSlide, nextSlide) { + sliderRef.current?.slickGoTo(nextSlide) + }, + initialSlide: initialImage.current ?? 0, + } + + return ( + + + + {sliderImages.map((image, index) => ( +
+ {image.fileName} +
+ ))} +
+
+
+ ) +} diff --git a/src/components/common/campaign-file/roles.ts b/src/components/common/campaign-file/roles.ts index 90294012c..57feacca7 100644 --- a/src/components/common/campaign-file/roles.ts +++ b/src/components/common/campaign-file/roles.ts @@ -11,6 +11,12 @@ export enum CampaignFileRole { organizerPhoto = 'organizerPhoto', } +export type ImageSlider = { + id: string + src: string + fileName: string +} + export enum CampaignNewsFileRole { invoice = 'invoice', gallery = 'gallery', diff --git a/src/components/common/withFullScreenSlider.tsx b/src/components/common/withFullScreenSlider.tsx new file mode 100644 index 000000000..66b81547b --- /dev/null +++ b/src/components/common/withFullScreenSlider.tsx @@ -0,0 +1,49 @@ +import React, { useRef, useState } from 'react' +import { FullScreenImageSlider } from './CampaignImageSlider' +import { ImageProps } from 'next/image' +import { ImageSlider } from 'components/common/campaign-file/roles' + +type ChildComponentProps = ImageProps & { + images: ImageSlider[] + index: number +} + +/** + * HOC which expands an Image whenever user clicks it + * @param WrappedComponent Next's `Image` component + * @param images: An array of returned Images + * @param index: Index of the clicked image + * @returns + */ +export default function withFullScreenSlider(WrappedComponent: React.ComponentType) { + const [toggleFullScreeSlider, setToggleFullScreenSlider] = useState(false) + const initialImage = useRef(0) + + const onOpenFullScreenSlider = (index: number) => { + setToggleFullScreenSlider(true) + initialImage.current = index + } + + const onCloseFullScreenSlider = () => { + setToggleFullScreenSlider(false) + initialImage.current = 0 + } + + return (props: ChildComponentProps) => ( + <> + onOpenFullScreenSlider(props.index)} + style={{ ...props.style, cursor: 'pointer' }} + /> + {toggleFullScreeSlider && props.index === props.images.length - 1 && ( + + )} + + ) +} diff --git a/src/gql/campaigns.ts b/src/gql/campaigns.ts index b4b77de69..315372dbc 100644 --- a/src/gql/campaigns.ts +++ b/src/gql/campaigns.ts @@ -110,7 +110,7 @@ export type CampaignResponse = BaseCampaignResponse & { id: UUID person: { id: UUID; firstName: string; lastName: string; email: string } } - campaignFiles?: CampaignFile[] + campaignFiles: CampaignFile[] | [] vaults?: VaultResponse[] defaultVault?: UUID campaignNews: CampaignNewsResponse[] | []