Skip to content

feat: add gallery endpoint #1297

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,32 @@ export const AreaAndClimbPageActions: React.FC<{ uuid: string, name: string, tar
let enableEdit = true
let editLabel = 'Edit'
let navigateUuid = ''
let targetSlug = ''
switch (targetType) {
case TagTargetType.area:
targetSlug = 'area'
url = `/editArea/${uuid}/general`
sharePath = `/area/${uuid}`
navigateUuid = uuid
break
case TagTargetType.climb:
targetSlug = 'climb'
url = `/editClimb/${uuid}`
sharePath = `/climb/${uuid}`
enableEdit = true
editLabel = 'Edit'
navigateUuid = parentUuid ?? ''
}

return (
<ul className='flex items-center justify-between gap-2'>
<Link
href={`/gallery/${uuid}?type=${targetSlug}`}
className='btn btn-primary'
>
SSR Gallery (in progress)
</Link>

<Link href={url} className={clz('btn no-animation shadow-md', enableEdit ? 'btn-solid btn-accent' : 'btn-disabled')}>
<PencilSimple size={20} weight='duotone' /> {editLabel}
</Link>
Expand Down
77 changes: 77 additions & 0 deletions src/app/(default)/gallery/@modal/(.)p/[uuid]/[photoId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React from 'react'
import { getEntityDataForPhotoDisplay } from '@/app/(default)/gallery/util/galleryUtils'
import PhotoDialogWrapper from '@/app/(default)/gallery/components/PhotoDialogWrapper'

interface PhotoModalProps {
params: {
uuid: string
photoId: string
}
searchParams: {
type?: 'area' | 'climb'
}
}

export default async function PhotoModal ({
params,
searchParams
}: PhotoModalProps): Promise<JSX.Element | null> {
const { uuid, photoId: currentPhotoId } = params
const type = searchParams.type

if (uuid === '' || currentPhotoId === '' || (type !== 'area' && type !== 'climb')) {
console.error(
'PhotoModal: Missing uuid, currentPhotoId, or type from searchParams.',
{ params, searchParams }
)
return null
}

const entityData = await getEntityDataForPhotoDisplay(uuid, type)

if ((entityData == null) || entityData.photos == null || entityData.photos.length === 0) {
console.warn(
`PhotoModal: No photos found for entity ${uuid} (type: ${type}).`
)
return (
<PhotoDialogWrapper
images={[]}
currentIndex={-1}
uuid={uuid}
type={type}
userinfoData={{ name: '' }}
authData={{ isAuthorized: false, isAuthenticated: false }}
/>
)
}

const photos = entityData.photos
const currentIndex = photos.findIndex((p) => p.id === currentPhotoId)

if (currentIndex === -1) {
console.warn(
`PhotoModal: Photo ID ${currentPhotoId} not found in entity ${uuid}.`
)
return (
<PhotoDialogWrapper
images={photos}
currentIndex={-1}
uuid={uuid}
type={type}
userinfoData={{ name: '' }}
authData={{ isAuthorized: false, isAuthenticated: false }}
/>
)
}

return (
<PhotoDialogWrapper
images={photos}
currentIndex={currentIndex}
uuid={uuid}
type={type}
userinfoData={{ name: '' }}
authData={{ isAuthorized: false, isAuthenticated: false }}
/>
)
}
3 changes: 3 additions & 0 deletions src/app/(default)/gallery/@modal/default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function ModalSlot (): null {
return null
}
177 changes: 177 additions & 0 deletions src/app/(default)/gallery/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import Image from 'next/image'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import {
getAreaPageFriendlyUrl,
getClimbPageFriendlyUrl,
parseUuidAsFirstParam
} from '@/js/utils'
import { PageWithCatchAllUuidProps } from '@/js/types/pages'
import { getAreaRSC } from '@/js/graphql/getAreaRSC'
import { getClimbByIdRSC } from '@/js/graphql/getClimbRSC'

interface GalleryPageProps extends PageWithCatchAllUuidProps {
searchParams?: { [key: string]: string | string[] | undefined }
}

interface PhotoData {
id: string
mediaUrl: string
[key: string]: any
}

interface EntityGalleryData {
id: string
name: string
photos: PhotoData[]
pageUrl: string
type: 'area' | 'climb'
}

type AreaData = NonNullable<Awaited<ReturnType<typeof getAreaRSC>>['area']>
type ClimbData = NonNullable<Awaited<ReturnType<typeof getClimbByIdRSC>>>

const formatAreaData = (area: AreaData): EntityGalleryData => {
const photos = (area.media ?? []).map(p => ({
...p,
id: p.id,
mediaUrl: p.mediaUrl
}))

return {
type: 'area',
id: area.uuid,
name: area.areaName,
photos,
pageUrl: getAreaPageFriendlyUrl(area.uuid, area.areaName)
}
}

const formatClimbData = (climb: ClimbData): EntityGalleryData => {
const photos = (climb.media ?? []).map(p => ({
...p,
id: p.id,
mediaUrl: p.mediaUrl
}))

return {
type: 'climb',
id: climb.id,
name: climb.name,
photos,
pageUrl: getClimbPageFriendlyUrl(climb.id, climb.name)
}
}

export default async function GalleryPage ({ params, searchParams }: GalleryPageProps): Promise<JSX.Element> {
const id = parseUuidAsFirstParam({ params })
const entityTypeParam = searchParams?.type as 'area' | 'climb' | undefined

if (id === '') {
notFound()
}

let galleryData: EntityGalleryData | null = null

try {
// If type param is 'area', fetch only AREA
if (entityTypeParam === 'area') {
const areaData = await getAreaRSC(id)
if (areaData?.area != null) {
galleryData = formatAreaData(areaData.area)
} else {
notFound()
}
} else if (entityTypeParam === 'climb') {
// If type param is 'climb', fetch only CLIMB
const climbData = await getClimbByIdRSC(id)
if (climbData != null) {
galleryData = formatClimbData(climbData)
} else {
notFound()
}
} else {
// If no type param or invalid, try to fetch both AREA and CLIMB
const areaData = await getAreaRSC(id)
if (areaData?.area != null) {
galleryData = formatAreaData(areaData.area)
} else {
const climbData = await getClimbByIdRSC(id)
if (climbData != null) {
galleryData = formatClimbData(climbData)
}
}
}
} catch (error) {
console.error(`Gallery data fetch failed for ID ${id}:`, error)
notFound()
}
// if no galleryData, show notFound
if (galleryData === null) {
notFound()
}

const { name: entityName, photos: photoList, pageUrl: entityPageUrl, type: entityType } = galleryData

return (
<Suspense fallback={<LoadingGridState />}>
<div className='container mx-auto px-4 py-8'>
<h1 className='text-3xl font-bold mb-2'>Photo Gallery for {entityName}</h1>
<p className='mb-4 text-secondary'>
<Link href={entityPageUrl} className='link-hover'>
&larr; Back to {entityName} {entityType} page
</Link>
</p>

{photoList.length === 0
? (
<div className='mt-8 p-4 bg-gray-100 rounded-lg text-center text-gray-600'>
<p>No photos have been uploaded for this {entityType} yet.</p>
<p><strong>TODO: Add a CTA to upload photos</strong></p>
</div>
)
: (
<div className='mt-8 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
{photoList.map((photo, index) => {
const imageAltText = `Photo ${index + 1} for ${entityName} (${entityType}) - ID: ${photo.id}`

return (
<Link
key={photo.id}
href={`/gallery/p/${id}/${photo.id}?type=${entityType}`}
className='relative aspect-square block bg-gray-100 rounded-lg overflow-hidden group'
>
<Image
src={photo.mediaUrl}
alt={imageAltText}
fill
sizes='(min-width: 1024px) 25vw, (min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw'
className='object-cover transition-transform duration-300 ease-in-out group-hover:scale-105'
priority={index < 4}
/>
</Link>
)
})}
</div>
)}
</div>
</Suspense>
)
}

function LoadingGridState (): JSX.Element {
return (
<div className='container mx-auto px-4 py-8'>
<div className='animate-pulse'>
<div className='h-8 bg-gray-200 rounded w-3/4 mb-2' />
<div className='h-4 bg-gray-200 rounded w-1/4 mb-8' />
<div className='mt-8 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
{[...Array(8)].map((_, index) => (
<div key={index} className='aspect-square bg-gray-200 rounded-lg' />
))}
</div>
</div>
</div>
)
}
17 changes: 17 additions & 0 deletions src/app/(default)/gallery/components/CardContentPlaceholder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import ContentLoader from 'react-content-loader'

export const CardContentPlaceholder: React.FC<{ uniqueKey?: string }> = (props) => (
<ContentLoader
uniqueKey={props.uniqueKey}
height={500}
speed={0}
backgroundColor='rgb(243 244 246)'
viewBox='0 0 300 400'
{...props}
>
<circle cx='30' cy='30' r='15' />
<rect x='58' y='24' rx='2' ry='2' width='140' height='10' />
<rect x='15' y='80' rx='10' ry='10' width='80' height='16' />
<rect x='105' y='80' rx='10' ry='10' width='80' height='16' />
</ContentLoader>
)
73 changes: 73 additions & 0 deletions src/app/(default)/gallery/components/DesktopModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client'

import * as Dialog from '@radix-ui/react-dialog'
import { XMarkIcon } from '@heroicons/react/24/outline'
import React, { ReactElement } from 'react'
import { Button, ButtonVariant } from '@/components/ui/BaseButton'

interface DesktopModalProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
mediaContainer: JSX.Element | null
rhsContainer: ReactElement
controlContainer?: JSX.Element | null
dialogTitle?: string
}

/**
* Full screen photo viewer using Radix UI Dialog
* Layout: Media on left/main, RHS panel for info, optional controls.
*/
export default function DesktopModal ({
isOpen,
onOpenChange,
mediaContainer,
rhsContainer,
controlContainer = null,
dialogTitle = 'Gallery Viewer'
}: DesktopModalProps): JSX.Element {
return (
<Dialog.Root
open={isOpen}
onOpenChange={onOpenChange}
>
<Dialog.Portal>
<Dialog.Overlay
className='fixed inset-0 z-40 bg-black/70 backdrop-blur-sm data-[state=open]:animate-overlayShow'
/>
<Dialog.Content
onEscapeKeyDown={() => onOpenChange(false)}
onPointerDownOutside={() => onOpenChange(false)}
className='fixed left-1/2 top-1/2 z-50 flex h-[90vh] w-[95vw] max-w-screen-2xl -translate-x-1/2 -translate-y-1/2 items-stretch bg-neutral text-neutral-content shadow-lg data-[state=open]:animate-contentShow focus:outline-none sm:rounded-lg overflow-hidden'
>
<Dialog.Title className='sr-only'>{dialogTitle}</Dialog.Title>

<div className='flex h-full w-full flex-col lg:flex-row'>
<div className='relative flex-grow bg-black flex items-center justify-center overflow-hidden p-2 lg:p-0'>
{mediaContainer}
</div>
<Dialog.Description asChild>
<div className='w-full lg:w-[350px] xl:w-[400px] shrink-0 bg-base-100 text-base-content h-auto max-h-[50vh] lg:max-h-full lg:h-full flex flex-col border-t lg:border-t-0 lg:border-l border-base-300 overflow-y-auto'>
{rhsContainer}
</div>
</Dialog.Description>
</div>

<Dialog.Close asChild>
<Button
ariaLabel='Close dialog'
label={<XMarkIcon className='h-5 w-5' />}
variant={ButtonVariant.ROUNDED_ICON_SOLID}
/>
</Dialog.Close>

{controlContainer != null && (
<div className='absolute bottom-4 left-1/2 z-10 -translate-x-1/2 flex justify-center'>
{controlContainer}
</div>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
Loading
Loading