diff --git a/bun.lock b/bun.lock index 2f22785..c59c644 100644 --- a/bun.lock +++ b/bun.lock @@ -10,9 +10,12 @@ "@payloadcms/plugin-seo": "^3.59.1", "@payloadcms/richtext-lexical": "3.59.1", "@payloadcms/ui": "3.59.1", + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", "cross-env": "^7.0.3", "dotenv": "16.4.7", "graphql": "^16.8.1", + "lucide-react": "^0.545.0", "next": "15.4.4", "payload": "3.59.1", "react": "19.1.0", @@ -482,6 +485,10 @@ "@playwright/test": ["@playwright/test@1.54.1", "", { "dependencies": { "playwright": "1.54.1" }, "bin": { "playwright": "cli.js" } }, "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.11", "", {}, "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="], @@ -882,6 +889,8 @@ "ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1320,6 +1329,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], diff --git a/package.json b/package.json index a969966..ff0f97d 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,12 @@ "@payloadcms/plugin-seo": "^3.59.1", "@payloadcms/richtext-lexical": "3.59.1", "@payloadcms/ui": "3.59.1", + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", "cross-env": "^7.0.3", "dotenv": "16.4.7", "graphql": "^16.8.1", + "lucide-react": "^0.545.0", "next": "15.4.4", "payload": "3.59.1", "react": "19.1.0", diff --git a/src/Header/Component.client.tsx b/src/Header/Component.client.tsx new file mode 100644 index 0000000..a95043e --- /dev/null +++ b/src/Header/Component.client.tsx @@ -0,0 +1,30 @@ +'use client' +import Link from 'next/link'; +import { usePathname } from 'next/navigation' +import React, { useEffect, useState } from 'react' + +import type { Header } from '@/src/payload-types' + +import { HeaderNav } from './Nav' +import { Logo } from '../components/Logo/Logo'; + +interface HeaderClientProps { + data: Header +} + +export const HeaderClient: React.FC = ({ data }) => { + /* Storing the value in a useState to avoid hydration errors */ + const [theme, setTheme] = useState(null) + const pathname = usePathname() + + return ( +
+
+ + + + +
+
+ ) +} \ No newline at end of file diff --git a/src/Header/Component.tsx b/src/Header/Component.tsx new file mode 100644 index 0000000..2e58b42 --- /dev/null +++ b/src/Header/Component.tsx @@ -0,0 +1,12 @@ + +import { getCachedGlobal } from '@/utilities/getGlobals'; +import React from 'react'; + +import type { Header } from '@/src/payload-types'; +import { HeaderClient } from './Component.client'; + +export async function Header() { + const headerData: Header = await getCachedGlobal('header', 1)() + + return +} \ No newline at end of file diff --git a/src/Header/Nav/index.tsx b/src/Header/Nav/index.tsx new file mode 100644 index 0000000..0417a24 --- /dev/null +++ b/src/Header/Nav/index.tsx @@ -0,0 +1,25 @@ +'use client' + +import React from 'react' + +import type { Header as HeaderType } from '@/src/payload-types' + +import { CMSLink } from '@/src/components/Link' +import Link from 'next/link' +import { SearchIcon } from 'lucide-react' + +export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => { + const navItems = data?.navItems || [] + + return ( + + ) +} \ No newline at end of file diff --git a/src/Header/RowLabel.tsx b/src/Header/RowLabel.tsx new file mode 100644 index 0000000..922169f --- /dev/null +++ b/src/Header/RowLabel.tsx @@ -0,0 +1,14 @@ +'use client' + +import { Header } from '@/src/payload-types' +import { RowLabelProps, useRowLabel } from '@payloadcms/ui'; + +export const RowLabel: React.FC = () => { + const data = useRowLabel[number]>() + + const label = data?.data?.link?.label + ? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}` + : 'Row' + + return
{label}
+} \ No newline at end of file diff --git a/src/Header/config.ts b/src/Header/config.ts new file mode 100644 index 0000000..2375cc4 --- /dev/null +++ b/src/Header/config.ts @@ -0,0 +1,31 @@ +import type { GlobalConfig } from 'payload'; +import { revalidateHeader } from './hooks/revalidateHeader'; +import { link } from '../fields/link'; + +export const Header: GlobalConfig = { + slug: 'header', + access: { + read: () => true, + }, + fields: [ + { + name: 'navItems', + type: 'array', + fields: [ + link({ + appearances: false, + }), + ], + maxRows: 6, + admin: { + initCollapsed: true, + components: { + RowLabel: '@/src/Header/RowLabel#RowLabel' + } + } + } + ], + hooks: { + afterChange: [revalidateHeader] + } +} \ No newline at end of file diff --git a/src/Header/hooks/revalidateHeader.ts b/src/Header/hooks/revalidateHeader.ts new file mode 100644 index 0000000..847f9fb --- /dev/null +++ b/src/Header/hooks/revalidateHeader.ts @@ -0,0 +1,13 @@ +import type { GlobalAfterChangeHook } from 'payload' + +import { revalidateTag } from 'next/cache' + +export const revalidateHeader: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => { + if (!context.disableRevalidate) { + payload.logger.info(`Revalidating header`) + + revalidateTag('global_header') + } + + return doc +} \ No newline at end of file diff --git a/src/components/Link/index.tsx b/src/components/Link/index.tsx new file mode 100644 index 0000000..f2a62fb --- /dev/null +++ b/src/components/Link/index.tsx @@ -0,0 +1,66 @@ +import { Button, type ButtonProps } from '@/src/components/ui/button' +import { cn } from '@/utilities/ui' +import Link from 'next/link' +import React from 'react' + +import type { Page } from '@/src/payload-types' + +type CMSLinkType = { + appearance?: 'inline' | ButtonProps['variant'] + children?: React.ReactNode + className?: string + label?: string | null + newTab?: boolean | null + reference?: { + relationTo: 'pages' | 'posts' + value: Page | string | number + } | null + size?: ButtonProps['size'] | null + type?: 'custom' | 'reference' | null + url?: string | null +} + +export const CMSLink: React.FC = (props) => { + const { + type, + appearance = 'inline', + children, + className, + label, + newTab, + reference, + size: sizeFromProps, + url, + } = props + + const href = + type === 'reference' && typeof reference?.value === 'object' && reference.value.slug + ? `${reference?.relationTo !== 'pages' ? `/${reference?.relationTo}` : ''}/${ + reference.value.slug + }` + : url + + if (!href) return null + + const size = appearance === 'link' ? 'clear' : sizeFromProps + const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {} + + /* Ensure we don't break any styles set by richText */ + if (appearance === 'inline') { + return ( + + {label && label} + {children && children} + + ) + } + + return ( + + ) +} \ No newline at end of file diff --git a/src/components/Logo/Logo.tsx b/src/components/Logo/Logo.tsx new file mode 100644 index 0000000..48c0ea9 --- /dev/null +++ b/src/components/Logo/Logo.tsx @@ -0,0 +1,29 @@ +import clsx from 'clsx' +import React from 'react' + +interface Props { + className?: string + loading?: 'lazy' | 'eager' + priority?: 'auto' | 'high' | 'low' +} + +export const Logo = (props: Props) => { + const { loading: loadingFromProps, priority: priorityFromProps, className } = props + + const loading = loadingFromProps || 'lazy' + const priority = priorityFromProps || 'low' + + return ( + /* eslint-disable @next/next/no-img-element */ + Payload Logo + ) +} \ No newline at end of file diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..aff5a3d --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import { cn } from '@/utilities/ui' +import { Slot } from '@radix-ui/react-slot' +import { type VariantProps, cva } from 'class-variance-authority' +import * as React from 'react' + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + defaultVariants: { + size: 'default', + variant: 'default', + }, + variants: { + size: { + clear: '', + default: 'h-10 px-4 py-2', + icon: 'h-10 w-10', + lg: 'h-11 rounded px-8', + sm: 'h-9 rounded px-3', + }, + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + ghost: 'hover:bg-card hover:text-accent-foreground', + link: 'text-primary items-start justify-start underline-offset-4 hover:underline', + outline: 'border border-border bg-background hover:bg-card hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + }, + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean + ref?: React.Ref + } + +const Button: React.FC = ({ + asChild = false, + className, + size, + variant, + ref, + ...props +}) => { + const Comp = asChild ? Slot : 'button' + return +} + +export { Button, buttonVariants } \ No newline at end of file diff --git a/src/fields/link.ts b/src/fields/link.ts new file mode 100644 index 0000000..4743cbb --- /dev/null +++ b/src/fields/link.ts @@ -0,0 +1,139 @@ +import type { Field, GroupField } from 'payload' + +import deepMerge from '@/utilities/deepMerge' + +export type LinkAppearances = 'default' | 'outline' + +export const appearanceOptions: Record = { + default: { + label: 'Default', + value: 'default', + }, + outline: { + label: 'Outline', + value: 'outline', + }, +} + +type LinkType = (options?: { + appearances?: LinkAppearances[] | false + disableLabel?: boolean + overrides?: Partial +}) => Field + +export const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => { + const linkResult: GroupField = { + name: 'link', + type: 'group', + admin: { + hideGutter: true, + }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'type', + type: 'radio', + admin: { + layout: 'horizontal', + width: '50%', + }, + defaultValue: 'reference', + options: [ + { + label: 'Internal link', + value: 'reference', + }, + { + label: 'Custom URL', + value: 'custom', + }, + ], + }, + { + name: 'newTab', + type: 'checkbox', + admin: { + style: { + alignSelf: 'flex-end', + }, + width: '50%', + }, + label: 'Open in new tab', + }, + ], + }, + ], + } + + const linkTypes: Field[] = [ + { + name: 'reference', + type: 'relationship', + admin: { + condition: (_, siblingData) => siblingData?.type === 'reference', + }, + label: 'Document to link to', + relationTo: ['pages',], + required: true, + }, + { + name: 'url', + type: 'text', + admin: { + condition: (_, siblingData) => siblingData?.type === 'custom', + }, + label: 'Custom URL', + required: true, + }, + ] + + if (!disableLabel) { + linkTypes.map((linkType) => ({ + ...linkType, + admin: { + ...linkType.admin, + width: '50%', + }, + })) + + linkResult.fields.push({ + type: 'row', + fields: [ + ...linkTypes, + { + name: 'label', + type: 'text', + admin: { + width: '50%', + }, + label: 'Label', + required: true, + }, + ], + }) + } else { + linkResult.fields = [...linkResult.fields, ...linkTypes] + } + + if (appearances !== false) { + let appearanceOptionsToUse = [appearanceOptions.default, appearanceOptions.outline] + + if (appearances) { + appearanceOptionsToUse = appearances.map((appearance) => appearanceOptions[appearance]) + } + + linkResult.fields.push({ + name: 'appearance', + type: 'select', + admin: { + description: 'Choose how the link should be rendered.', + }, + defaultValue: 'default', + options: appearanceOptionsToUse, + }) + } + + return deepMerge(linkResult, overrides) +} \ No newline at end of file diff --git a/src/payload-types.ts b/src/payload-types.ts index 9e04d61..c6b98a9 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -93,10 +93,12 @@ export interface Config { globals: { nav: Nav; settings: Setting; + header: Header; }; globalsSelect: { nav: NavSelect | NavSelect; settings: SettingsSelect | SettingsSelect; + header: HeaderSelect | HeaderSelect; }; locale: null; user: User & { @@ -473,6 +475,30 @@ export interface Setting { updatedAt?: string | null; createdAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "header". + */ +export interface Header { + id: number; + navItems?: + | { + link: { + type?: ('reference' | 'custom') | null; + newTab?: boolean | null; + reference?: { + relationTo: 'pages'; + value: number | Page; + } | null; + url?: string | null; + label: string; + }; + id?: string | null; + }[] + | null; + updatedAt?: string | null; + createdAt?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "nav_select". @@ -498,6 +524,29 @@ export interface SettingsSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "header_select". + */ +export interface HeaderSelect { + navItems?: + | T + | { + link?: + | T + | { + type?: T; + newTab?: T; + reference?: T; + url?: T; + label?: T; + }; + id?: T; + }; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/src/payload.config.ts b/src/payload.config.ts index 04c8147..1614687 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -15,6 +15,7 @@ import { Categories } from './collections/Categories' import { Nav } from './collections/Nav' import { plugins } from './plugins' import { Settings } from './collections/Settings' +import { Header } from './Header/config' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -36,6 +37,7 @@ export default buildConfig({ globals: [ Nav, Settings, + Header ], editor: lexicalEditor({ features: ({ defaultFeatures, rootFeatures }) => [ diff --git a/utilities/deepMerge.ts b/utilities/deepMerge.ts new file mode 100644 index 0000000..fee4aea --- /dev/null +++ b/utilities/deepMerge.ts @@ -0,0 +1,35 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +export function isObject(item: unknown): item is object { + return typeof item === 'object' && !Array.isArray(item) +} + +/** + * Deep merge two objects. + * @param target + * @param ...sources + */ +export default function deepMerge(target: T, source: R): T { + const output = { ...target } + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach((key) => { + if (isObject(source[key])) { + if (!(key in target)) { + Object.assign(output, { [key]: source[key] }) + } else { + output[key] = deepMerge(target[key], source[key]) + } + } else { + Object.assign(output, { [key]: source[key] }) + } + }) + } + + return output +} \ No newline at end of file diff --git a/utilities/getGlobals.ts b/utilities/getGlobals.ts new file mode 100644 index 0000000..5926501 --- /dev/null +++ b/utilities/getGlobals.ts @@ -0,0 +1,23 @@ +import type { Config } from 'src/payload-types'; + +import configPromise from '@payload-config'; +import { getPayload } from 'payload'; +import { unstable_cache } from 'next/cache'; + +type Global = keyof Config['globals'] + +async function getGlobal(slug: Global, depth = 0) { + const payload = await getPayload({ config: configPromise }) + + const global = await payload.findGlobal({ + slug, + depth, + }) + + return global +} + +export const getCachedGlobal = (slug: Global, depth = 0) => + unstable_cache(async () => getGlobal(slug, depth), [slug], { + tags: [`global_${slug}`], + }) \ No newline at end of file