Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/collections/Career/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {

import { authenticated } from '../../access/authenticated'
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { listGallery } from '../../components/ListGallery/config'

export const Career: CollectionConfig<'career'> = {
slug: 'career',
Expand All @@ -19,6 +20,7 @@ export const Career: CollectionConfig<'career'> = {
},
admin: {
defaultColumns: ['company', 'jobTitle', 'startDate', 'updatedAt'],
components: listGallery('logo'),
useAsTitle: 'company',
},
fields: [
Expand Down
5 changes: 5 additions & 0 deletions src/collections/PhotoCollections/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { slugField } from 'payload'
import { anyone } from '../../access/anyone'
import { revalidateDelete, revalidatePhotoCollection } from './hooks/revalidatePhotoCollection'
import { authenticated } from '../../access/authenticated'
import { listGallery } from '../../components/ListGallery/config'

export const PhotoCollections: CollectionConfig = {
slug: 'photo-collections',
Expand All @@ -26,6 +27,10 @@ export const PhotoCollections: CollectionConfig = {
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'slug', 'parent', 'displayOrder', 'hiddenFromIndex', 'updatedAt'],
components: listGallery(
'coverImage',
'@/components/ListGallery/PhotoCollectionsGallery#PhotoCollectionsGallery',
),
},
fields: [
{
Expand Down
2 changes: 2 additions & 0 deletions src/collections/Photos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fileURLToPath } from 'url'

import { anyone } from '../access/anyone'
import { authenticated } from '../access/authenticated'
import { listGallery } from '../components/ListGallery/config'
import { appendPrefixToCollectionBeforeChangeHook } from '../hooks/uploadPrefixed'
import { revalidateDelete, revalidatePhoto } from './Photos/hooks/revalidatePhoto'
import { transcodeHeicUpload } from './Photos/hooks/transcodeHeicUpload'
Expand All @@ -22,6 +23,7 @@ export const Photos: CollectionConfig = {
admin: {
useAsTitle: 'filename',
defaultColumns: ['filename', 'alt', 'tags', 'updatedAt'],
components: listGallery(),
},
folders: true,
hooks: {
Expand Down
2 changes: 2 additions & 0 deletions src/collections/Posts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

import { authenticated } from '../../access/authenticated'
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { listGallery } from '../../components/ListGallery/config'
import { Banner } from '../../blocks/Banner/config'
import { Code } from '../../blocks/Code/config'
import { MediaBlock } from '../../blocks/MediaBlock/config'
Expand Down Expand Up @@ -49,6 +50,7 @@ export const Posts: CollectionConfig<'posts'> = {
},
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
components: listGallery('heroImage'),
livePreview: {
url: ({ data, req }) =>
generatePreviewPath({
Expand Down
2 changes: 2 additions & 0 deletions src/collections/Projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {

import { authenticated } from '../../access/authenticated'
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { listGallery } from '../../components/ListGallery/config'
import { slugField } from 'payload'
import { revalidateDelete, revalidateProject } from './hooks/revalidateProject'

Expand All @@ -22,6 +23,7 @@ export const Projects: CollectionConfig<'projects'> = {
},
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
components: listGallery('featuredImage'),
useAsTitle: 'title',
},
fields: [
Expand Down
89 changes: 89 additions & 0 deletions src/components/ListGallery/ListView.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use client'

import type { ListViewClientProps } from 'payload'

import {
Button,
DefaultListView,
GridViewIcon,
ListViewIcon,
useListQuery,
usePreferences,
} from '@payloadcms/ui'
import React, { createContext, use, useState } from 'react'

import type { ListLayout } from './layout'

import { listLayoutPreferenceKey } from './layout'
import './index.scss'

const ListLayoutContext = createContext<ListLayout>('table')

export const useListLayout = () => use(ListLayoutContext)

const baseClass = 'list-gallery-toggle'

const LayoutToggle: React.FC<{ layout: ListLayout; onChange: (layout: ListLayout) => void }> = ({
layout,
onChange,
}) => {
const { isGroupingBy } = useListQuery()

// Grouped lists render one table per group; the gallery only knows the ungrouped page.
if (isGroupingBy) return null

return (
<React.Fragment>
<Button
aria-label="Table view"
buttonStyle="pill"
className={[baseClass, layout === 'table' && 'is-active'].filter(Boolean).join(' ')}
icon={<ListViewIcon />}
margin={false}
onClick={() => onChange('table')}
tooltip="Table"
/>
<Button
aria-label="Gallery view"
buttonStyle="pill"
className={[baseClass, layout === 'gallery' && 'is-active'].filter(Boolean).join(' ')}
icon={<GridViewIcon />}
margin={false}
onClick={() => onChange('gallery')}
tooltip="Gallery"
/>
</React.Fragment>
)
}

/**
* Payload's default list view with a Table/Gallery switch in the search bar. The gallery
* itself is the `ListGallery` server component in the `beforeListTable` slot, which reads the
* layout from this component's context.
*/
export const GalleryListViewClient: React.FC<
{ initialLayout: ListLayout } & ListViewClientProps
> = ({ initialLayout, ...props }) => {
const { beforeActions, collectionSlug } = props
const { setPreference } = usePreferences()
const [layout, setLayout] = useState<ListLayout>(initialLayout)

const changeLayout = (next: ListLayout) => {
setLayout(next)
// Its own key, written whole: see `listLayoutPreferenceKey`. The server component in
// `./ListView` reads it back as `initialLayout` on the next render.
void setPreference(listLayoutPreferenceKey(collectionSlug), next)
}

return (
<ListLayoutContext value={layout}>
<DefaultListView
{...props}
beforeActions={[
...(beforeActions ?? []),
<LayoutToggle key="list-layout" layout={layout} onChange={changeLayout} />,
]}
/>
</ListLayoutContext>
)
}
64 changes: 64 additions & 0 deletions src/components/ListGallery/ListView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ListViewClientProps, ListViewServerProps, ListViewServerPropsOnly } from 'payload'

import React from 'react'

import type { ListLayout } from './layout'

import { listLayoutPreferenceKey } from './layout'
import { GalleryListViewClient } from './ListView.client'

// What Payload adds for server components on top of a list view's client props. Most of it
// (payload, i18n, the collection config) can't cross to the client view.
const serverOnlyProps = new Set<string>([
'collectionConfig',
'data',
'i18n',
'limit',
'listSearchableFields',
'locale',
'params',
'payload',
'permissions',
'searchParams',
'user',
] satisfies (keyof ListViewServerPropsOnly)[])

const getListLayout = async ({
collectionSlug,
payload,
user,
}: Pick<ListViewServerProps, 'collectionSlug' | 'payload' | 'user'>): Promise<ListLayout> => {
if (!user) return 'table'

// Same lookup as Payload's own preference reads: access is bypassed, the query is scoped
// to the current user instead.
const { docs } = await payload.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
pagination: false,
where: {
and: [
{ key: { equals: listLayoutPreferenceKey(collectionSlug) } },
{ 'user.relationTo': { equals: user.collection } },
{ 'user.value': { equals: user.id } },
],
},
})

return docs[0]?.value === 'gallery' ? 'gallery' : 'table'
}

/**
* List view for collections registered with `listGallery` in `./config`: Payload's default
* list view plus a Table/Gallery switch, opened in the layout the user last chose for this
* collection.
*/
export const GalleryListView = async (props: ListViewServerProps) => {
const initialLayout = await getListLayout(props)
const clientProps = Object.fromEntries(
Object.entries(props).filter(([key]) => !serverOnlyProps.has(key)),
) as ListViewClientProps

return <GalleryListViewClient {...clientProps} initialLayout={initialLayout} />
}
32 changes: 32 additions & 0 deletions src/components/ListGallery/PhotoCollectionsGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { PhotoCollection } from '@/payload-types'

import React from 'react'

import { getPhotoCollectionWhere } from '@/utilities/getPhotoCollectionWhere'

import type { ListGalleryProps } from './index'

import { ListGallery } from './index'

/**
* List gallery for photo collections. Mirrors `getRepresentativePhoto`: a collection without
* a cover image is represented by the first photo matching its filter.
*/
export const PhotoCollectionsGallery = (props: ListGalleryProps) => (
<ListGallery
{...props}
fallbackImage={async (doc, { payload, user }) => {
const { docs } = await payload.find({
collection: 'photos',
depth: 0,
limit: 1,
overrideAccess: false,
pagination: false,
sort: 'createdAt',
user,
where: getPhotoCollectionWhere(doc as unknown as PhotoCollection),
})
return docs[0] ?? null
}}
/>
)
18 changes: 18 additions & 0 deletions src/components/ListGallery/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { CollectionConfig } from 'payload'

type ListComponents = NonNullable<NonNullable<CollectionConfig['admin']>['components']>

/**
* `admin.components` for a collection whose list view can switch between Table and Gallery.
* `imageField` names the top-level upload field to preview; omit it on an upload collection,
* where the document itself is the image. `gallery` swaps in a wrapper around `ListGallery`.
*/
export const listGallery = (
imageField?: string,
gallery = '@/components/ListGallery#ListGallery',
): ListComponents => ({
beforeListTable: [{ path: gallery, serverProps: { imageField } }],
views: {
list: { Component: '@/components/ListGallery/ListView#GalleryListView' },
},
})
Loading
Loading