changes
This commit is contained in:
parent
73abb661c6
commit
891bcaea93
3 changed files with 99 additions and 147 deletions
|
|
@ -2,13 +2,14 @@ import { withPayload } from '@payloadcms/next/withPayload'
|
||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from 'next'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(__filename)
|
const dirname = path.dirname(__filename)
|
||||||
import { redirects } from './redirects'
|
import { redirects } from './redirects'
|
||||||
|
|
||||||
const NEXT_PUBLIC_SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'
|
const NEXT_PUBLIC_SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'
|
||||||
|
const S3_BUCKET_URL = process.env.NEXT_PUBLIC_S3_URL || ''
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
sassOptions: {
|
sassOptions: {
|
||||||
|
|
@ -20,14 +21,14 @@ const nextConfig: NextConfig = {
|
||||||
pathname: '/api/media/file/**',
|
pathname: '/api/media/file/**',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
qualities: [100],
|
qualities: [25, 50, 75, 100],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
...[NEXT_PUBLIC_SERVER_URL].map((item) => {
|
...[NEXT_PUBLIC_SERVER_URL, S3_BUCKET_URL].filter(Boolean).map((item) => {
|
||||||
const url = new URL(item)
|
const url = new URL(item)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
protocol: url.protocol.replace(':', '') as 'http' | 'https',
|
protocol: url.protocol.replace(':', '') as 'http' | 'https',
|
||||||
|
pathname: '/**',
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
@ -38,22 +39,21 @@ const nextConfig: NextConfig = {
|
||||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
||||||
'.mjs': ['.mts', '.mjs'],
|
'.mjs': ['.mts', '.mjs'],
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceHash = (val: unknown) =>
|
const replaceHash = (val: unknown) =>
|
||||||
typeof val === 'string' ? val.replace('[chunkhash]', '[contenthash]') : val
|
typeof val === 'string' ? val.replace('[chunkhash]', '[contenthash]') : val
|
||||||
|
|
||||||
webpackConfig.output.filename = replaceHash(webpackConfig.output.filename)
|
webpackConfig.output.filename = replaceHash(webpackConfig.output.filename)
|
||||||
webpackConfig.output.chunkFilename = replaceHash(webpackConfig.output.chunkFilename)
|
webpackConfig.output.chunkFilename = replaceHash(webpackConfig.output.chunkFilename)
|
||||||
|
|
||||||
return webpackConfig
|
return webpackConfig
|
||||||
},
|
},
|
||||||
|
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
redirects,
|
redirects,
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: path.resolve(dirname),
|
root: path.resolve(dirname),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
||||||
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
// Import the generated Media type from your Payload config
|
||||||
type MediaObject = {
|
import type { Media as MediaType } from '@/payload-types'
|
||||||
url?: string
|
|
||||||
alt?: string
|
|
||||||
width?: number
|
|
||||||
height?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShowcaseItem = {
|
type ShowcaseItem = {
|
||||||
image?: MediaObject | null
|
image?: MediaType | null
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
|
@ -23,39 +18,47 @@ type ShowcaseBlockProps = {
|
||||||
items?: ShowcaseItem[]
|
items?: ShowcaseItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShowcaseImage(props: { item: ShowcaseItem }): React.ReactElement {
|
function ShowcaseImage({ item }: { item: ShowcaseItem }) {
|
||||||
const item = props.item
|
// Payload 3.0 returns the object directly if depth is configured
|
||||||
const imageUrl = item.image != null && typeof item.image === 'object' ? item.image.url : null
|
const image = item.image
|
||||||
const imageAlt = item.image != null && typeof item.image === 'object' ? item.image.alt : item.title
|
|
||||||
|
|
||||||
if (imageUrl != null && item.imageUrl != null) {
|
// Use the image object if available, otherwise fallback
|
||||||
return (
|
if (image && typeof image === 'object' && 'url' in image) {
|
||||||
|
const content = (
|
||||||
href={item.imageUrl}
|
<div className="relative w-full aspect-video overflow-hidden rounded-t-xl group">
|
||||||
target="_blank"
|
<Image src={image.url!} alt={image.alt ?? item.title} fill className="object-cover" />
|
||||||
rel="noopener noreferrer"
|
{/* Hover Overlay */}
|
||||||
className="block relative w-full aspect-video overflow-hidden rounded-t-xl group"
|
|
||||||
>
|
|
||||||
<Image src={imageUrl} alt={imageAlt ?? item.title} fill className="object-cover" />
|
|
||||||
<div className="absolute inset-0 bg-background/0 group-hover:bg-background/20 transition-colors duration-200" />
|
<div className="absolute inset-0 bg-background/0 group-hover:bg-background/20 transition-colors duration-200" />
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
{item.imageUrl && (
|
||||||
<i className="ti ti-external-link text-foreground/80" style={{ fontSize: 20 }} aria-hidden="true" />
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
</div>
|
<i
|
||||||
</a>
|
className="ti ti-external-link text-foreground/80"
|
||||||
)
|
style={{ fontSize: 20 }}
|
||||||
}
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
if (imageUrl != null) {
|
</div>
|
||||||
return (
|
)}
|
||||||
<div className="relative w-full aspect-video overflow-hidden rounded-t-xl">
|
|
||||||
<Image src={imageUrl} alt={imageAlt ?? item.title} fill className="object-cover" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (item.imageUrl) {
|
||||||
|
return (
|
||||||
|
<a href={item.imageUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Placeholder if no image
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full aspect-video overflow-hidden rounded-t-xl bg-muted/50 flex flex-col items-center justify-center gap-2">
|
<div className="relative w-full aspect-video overflow-hidden rounded-t-xl bg-muted/50 flex flex-col items-center justify-center gap-2">
|
||||||
<i className="ti ti-photo-off text-foreground/15" style={{ fontSize: 32 }} aria-hidden="true" />
|
<i
|
||||||
|
className="ti ti-photo-off text-foreground/15"
|
||||||
|
style={{ fontSize: 32 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<span className="text-xs text-foreground/20">Coming soon</span>
|
<span className="text-xs text-foreground/20">Coming soon</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -82,59 +85,54 @@ export function ShowcaseBlock(props: ShowcaseBlockProps): React.ReactElement | n
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{items.map(function (item, i) {
|
{items.map((item, i) => (
|
||||||
return (
|
<div key={i} className="bg-muted/50 border-0 rounded-xl overflow-hidden flex flex-col">
|
||||||
<div
|
<ShowcaseImage item={item} />
|
||||||
key={i}
|
|
||||||
className="bg-muted/50 border-0 rounded-xl overflow-hidden flex flex-col"
|
|
||||||
>
|
|
||||||
<ShowcaseImage item={item} />
|
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3 flex-1">
|
<div className="p-4 flex flex-col gap-3 flex-1">
|
||||||
<h3 className="text-sm font-medium text-foreground/85">{item.title}</h3>
|
<h3 className="text-sm font-medium text-foreground/85">{item.title}</h3>
|
||||||
|
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p className="text-xs text-foreground/50 leading-relaxed">{item.description}</p>
|
<p className="text-xs text-foreground/50 leading-relaxed">{item.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Array.isArray(item.tags) && item.tags.length > 0 && (
|
{item.tags && item.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{item.tags.map(function (t, j) {
|
{item.tags.map((t, j) => (
|
||||||
return (
|
<span
|
||||||
<span
|
key={j}
|
||||||
key={j}
|
className="text-xs text-foreground/50 bg-background/50 border border-foreground/10 rounded-full px-2.5 py-0.5"
|
||||||
className="text-xs text-foreground/50 bg-background/50 border border-foreground/10 rounded-full px-2.5 py-0.5"
|
>
|
||||||
>
|
{t.tag}
|
||||||
{t.tag}
|
</span>
|
||||||
</span>
|
))}
|
||||||
)
|
</div>
|
||||||
})}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Array.isArray(item.links) && item.links.length > 0 && (
|
{item.links && item.links.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-3 mt-auto pt-2 border-t border-foreground/8">
|
<div className="flex flex-wrap gap-3 mt-auto pt-2 border-t border-foreground/10">
|
||||||
{item.links.map(function (link, k) {
|
{item.links.map((link, k) => (
|
||||||
return (
|
<a
|
||||||
|
key={k}
|
||||||
key={k}
|
href={link.url}
|
||||||
href={link.url}
|
target={link.newTab ? '_blank' : '_self'}
|
||||||
target={link.newTab ? '_blank' : '_self'}
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
className="flex items-center gap-1.5 text-xs text-foreground/50 hover:text-foreground/80 transition-colors"
|
||||||
className="flex items-center gap-1.5 text-xs text-foreground/50 hover:text-foreground/80 transition-colors"
|
>
|
||||||
>
|
<i
|
||||||
<i className="ti ti-external-link" style={{ fontSize: 13 }} aria-hidden="true" />
|
className="ti ti-external-link"
|
||||||
{link.label}
|
style={{ fontSize: 13 }}
|
||||||
</a>
|
aria-hidden="true"
|
||||||
)
|
/>
|
||||||
})}
|
{link.label}
|
||||||
</div>
|
</a>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,11 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { StaticImageData } from 'next/image'
|
|
||||||
|
|
||||||
import { cn } from '@/utilities/ui'
|
import { cn } from '@/utilities/ui'
|
||||||
import NextImage from 'next/image'
|
import NextImage from 'next/image'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import type { Props as MediaProps } from '../types'
|
import type { Props as MediaProps } from '../types'
|
||||||
|
|
||||||
import { cssVariables } from '@/cssVariables'
|
|
||||||
import { getMediaUrl } from '@/utilities/getMediaUrl'
|
import { getMediaUrl } from '@/utilities/getMediaUrl'
|
||||||
|
|
||||||
const { breakpoints } = cssVariables
|
|
||||||
|
|
||||||
// A base64 encoded image to use as a placeholder while the image is loading
|
|
||||||
const placeholderBlur =
|
|
||||||
'data:image/png;base64,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'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ImageMedia
|
|
||||||
*
|
|
||||||
* This component passes a **relative** `src` (e.g. `/media/...`) to Next.js Image.
|
|
||||||
* The `getMediaUrl` utility constructs the full URL by prepending the base URL from env vars
|
|
||||||
* (NEXT_PUBLIC_SERVER_URL). Next.js then optimizes this using `remotePatterns` configured
|
|
||||||
* in next.config.js — no custom `loader` needed.
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Resource URL from Payload: `/media/image-123.jpg`
|
|
||||||
* 2. getMediaUrl() adds base URL: `https://yourdomain.com/media/image-123.jpg`
|
|
||||||
* 3. Next.js Image optimizes via remotePatterns: `/_next/image?url=...&w=1200&q=75`
|
|
||||||
*
|
|
||||||
* If your storage/plugin returns **external CDN URLs** (e.g. `https://cdn.example.com/...`),
|
|
||||||
* choose ONE of the following:
|
|
||||||
* A) Allow the remote host in next.config.js:
|
|
||||||
* images: { remotePatterns: [{ protocol: 'https', hostname: 'cdn.example.com' }] }
|
|
||||||
* B) Provide a **custom loader** for CDN-specific transforms:
|
|
||||||
* const imageLoader: ImageLoader = ({ src, width, quality }) =>
|
|
||||||
* `https://cdn.example.com${src}?w=${width}&q=${quality ?? 75}`
|
|
||||||
* <Image loader={imageLoader} src="/media/hero.jpg" width={1200} height={600} alt="" />
|
|
||||||
* C) Skip optimization:
|
|
||||||
* <Image unoptimized src="https://cdn.example.com/hero.jpg" width={1200} height={600} alt="" />
|
|
||||||
*
|
|
||||||
* TL;DR: Template uses relative URLs + getMediaUrl() to construct full URLs, then relies on
|
|
||||||
* remotePatterns for optimization. Only add `loader` if using external CDNs with custom transforms.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const ImageMedia: React.FC<MediaProps> = (props) => {
|
export const ImageMedia: React.FC<MediaProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
alt: altFromProps,
|
alt: altFromProps,
|
||||||
|
|
@ -61,28 +22,23 @@ export const ImageMedia: React.FC<MediaProps> = (props) => {
|
||||||
let width: number | undefined
|
let width: number | undefined
|
||||||
let height: number | undefined
|
let height: number | undefined
|
||||||
let alt = altFromProps
|
let alt = altFromProps
|
||||||
let src: StaticImageData | string = srcFromProps || ''
|
let src: string = srcFromProps || ''
|
||||||
|
|
||||||
if (!src && resource && typeof resource === 'object') {
|
if (!src && resource && typeof resource === 'object') {
|
||||||
const { alt: altFromResource, height: fullHeight, url, width: fullWidth } = resource
|
const { alt: altFromResource, height: fullHeight, url, width: fullWidth } = resource
|
||||||
|
|
||||||
width = fullWidth!
|
width = fullWidth || undefined
|
||||||
height = fullHeight!
|
height = fullHeight || undefined
|
||||||
alt = altFromResource || ''
|
alt = altFromResource || ''
|
||||||
|
|
||||||
const cacheTag = resource.updatedAt
|
src = getMediaUrl(url)
|
||||||
|
|
||||||
src = getMediaUrl(url, cacheTag)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!src) return null
|
||||||
|
|
||||||
const loading = loadingFromProps || (!priority ? 'lazy' : undefined)
|
const loading = loadingFromProps || (!priority ? 'lazy' : undefined)
|
||||||
|
|
||||||
// NOTE: this is used by the browser to determine which image to download at different screen sizes
|
const sizes = sizeFromProps || '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
|
||||||
const sizes = sizeFromProps
|
|
||||||
? sizeFromProps
|
|
||||||
: Object.entries(breakpoints)
|
|
||||||
.map(([, value]) => `(max-width: ${value}px) ${value * 2}w`)
|
|
||||||
.join(', ')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<picture className={cn(pictureClassName)}>
|
<picture className={cn(pictureClassName)}>
|
||||||
|
|
@ -91,10 +47,8 @@ export const ImageMedia: React.FC<MediaProps> = (props) => {
|
||||||
className={cn(imgClassName)}
|
className={cn(imgClassName)}
|
||||||
fill={fill}
|
fill={fill}
|
||||||
height={!fill ? height : undefined}
|
height={!fill ? height : undefined}
|
||||||
placeholder="blur"
|
|
||||||
blurDataURL={placeholderBlur}
|
|
||||||
priority={priority}
|
priority={priority}
|
||||||
quality={100}
|
quality={75}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
src={src}
|
src={src}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue