From 41d784e92d459401d4b7826acb0b766bb4d1987e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 13:03:04 -0700 Subject: [PATCH] Fix store images: bypass Cloudflare Access via internal Docker URL Directus CMS is behind Cloudflare Access, which blocks the Next.js image optimizer from fetching assets. Route image requests through the internal Docker network (http://katheryn-cms:8055) instead. - Add NEXT_PUBLIC_DIRECTUS_ASSET_URL/TOKEN env vars for client components - Use DIRECTUS_INTERNAL_URL for server-side Directus API calls - Convert store detail page from client to server component (data fetching now happens server-side, not in browser) - Add internal Docker hostname to Next.js remotePatterns Co-Authored-By: Claude Opus 4.6 --- frontend/Dockerfile | 4 + frontend/docker-compose.yml | 2 + frontend/next.config.ts | 6 + .../store/[slug]/artwork-detail-client.tsx | 259 ++++++++++++++ frontend/src/app/store/[slug]/page.tsx | 325 +++--------------- frontend/src/lib/directus.ts | 14 +- 6 files changed, 329 insertions(+), 281 deletions(-) create mode 100644 frontend/src/app/store/[slug]/artwork-detail-client.tsx diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e69ba84..3d22905 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,10 +12,14 @@ COPY . . # Build args ARG DIRECTUS_API_TOKEN +ARG NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055 +ARG NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN # Set environment for build ENV NEXT_PUBLIC_DIRECTUS_URL=https://katheryn-cms.jeffemmett.com ENV DIRECTUS_API_TOKEN=${DIRECTUS_API_TOKEN} +ENV NEXT_PUBLIC_DIRECTUS_ASSET_URL=${NEXT_PUBLIC_DIRECTUS_ASSET_URL} +ENV NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=${NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN} ENV NEXT_TELEMETRY_DISABLED=1 # Build the application diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml index 1f152ff..507d7c1 100644 --- a/frontend/docker-compose.yml +++ b/frontend/docker-compose.yml @@ -5,6 +5,8 @@ services: dockerfile: Dockerfile args: - DIRECTUS_API_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 + - NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055 + - NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 container_name: katheryn-frontend restart: unless-stopped env_file: diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 05ec20f..e06fa5e 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -15,6 +15,12 @@ const nextConfig: NextConfig = { port: '8055', pathname: '/assets/**', }, + { + protocol: 'http', + hostname: 'katheryn-cms', + port: '8055', + pathname: '/assets/**', + }, { protocol: 'https', hostname: 'images.squarespace-cdn.com', diff --git a/frontend/src/app/store/[slug]/artwork-detail-client.tsx b/frontend/src/app/store/[slug]/artwork-detail-client.tsx new file mode 100644 index 0000000..5804a4e --- /dev/null +++ b/frontend/src/app/store/[slug]/artwork-detail-client.tsx @@ -0,0 +1,259 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Artwork, getAssetUrl } from '@/lib/directus'; +import { useCart } from '@/context/cart-context'; + +interface ArtworkDetailClientProps { + artwork: Artwork; + relatedWorks: Artwork[]; + mainImageUrl: string; + galleryImageUrls: string[]; + thumbnailUrl: string; +} + +export default function ArtworkDetailClient({ + artwork, + relatedWorks, + mainImageUrl, + galleryImageUrls, + thumbnailUrl, +}: ArtworkDetailClientProps) { + const [selectedImage, setSelectedImage] = useState(0); + const { addItem, isInCart } = useCart(); + const inCart = isInCart(artwork.id); + const isSold = artwork.status === 'sold'; + const isAvailable = artwork.status === 'published' && artwork.price && artwork.price > 0; + + const handleAddToCart = () => { + if (artwork && isAvailable && !inCart) { + addItem(artwork); + } + }; + + const galleryImages = artwork.gallery || []; + + return ( +
+ {/* Breadcrumb */} +
+
+ +
+
+ + {/* Main content */} +
+
+ {/* Images */} +
+
+ {artwork.image ? ( + {artwork.title} + ) : ( +
+ No image available +
+ )} + {isSold && ( +
+ + Sold + +
+ )} +
+ + {/* Thumbnail gallery */} + {galleryImages.length > 0 && ( +
+ + {galleryImageUrls.map((url, index) => ( + + ))} +
+ )} +
+ + {/* Details */} +
+

{artwork.title}

+ + {artwork.year && ( +

{artwork.year}

+ )} + + {/* Price */} +
+ {isSold ? ( +

Sold

+ ) : artwork.price ? ( +

{artwork.currency === 'GBP' ? '£' : '$'}{artwork.price.toLocaleString()}

+ ) : ( +

Price on request

+ )} +
+ + {/* Add to cart */} + {isAvailable && !isSold && ( +
+ +
+ )} + + {/* Enquire button for sold or POA items */} + {(isSold || !artwork.price) && ( +
+ + Enquire About This Work + +
+ )} + + {/* Details */} +
+ {artwork.medium && ( +
+ Medium + {artwork.medium} +
+ )} + {artwork.dimensions && ( +
+ Dimensions + {artwork.dimensions} +
+ )} + {artwork.series && typeof artwork.series === 'object' && ( +
+ Series + + {artwork.series.name} + +
+ )} +
+ + {/* Description */} + {artwork.description && ( +
+

+ About This Work +

+
+
+ )} + + {/* Shipping info */} +
+
+ + + +
+

Secure Shipping

+

+ All artworks are carefully packaged and shipped with insurance. +

+
+
+
+
+
+
+ + {/* Related works */} + {relatedWorks.length > 0 && ( +
+
+

You May Also Like

+
+ {relatedWorks.map((work) => ( + +
+ {work.image && ( + {work.title} + )} +
+
+

{work.title}

+ {work.price && ( +

{work.currency === 'GBP' ? '£' : '$'}{work.price.toLocaleString()}

+ )} +
+ + ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/store/[slug]/page.tsx b/frontend/src/app/store/[slug]/page.tsx index e00fb95..9c82ff3 100644 --- a/frontend/src/app/store/[slug]/page.tsx +++ b/frontend/src/app/store/[slug]/page.tsx @@ -1,293 +1,62 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; import { getArtwork, getArtworks, getAssetUrl, Artwork } from '@/lib/directus'; -import { useCart } from '@/context/cart-context'; +import ArtworkDetailClient from './artwork-detail-client'; -export default function ArtworkDetailPage() { - const params = useParams(); - const slug = params.slug as string; - const [artwork, setArtwork] = useState(null); - const [relatedWorks, setRelatedWorks] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedImage, setSelectedImage] = useState(0); +// Force dynamic rendering to ensure fresh data from Directus +export const dynamic = 'force-dynamic'; +export const revalidate = 0; - const { addItem, isInCart } = useCart(); - const inCart = artwork ? isInCart(artwork.id) : false; - const isSold = artwork?.status === 'sold'; - const isAvailable = artwork?.status === 'published' && artwork?.price && artwork.price > 0; +interface PageProps { + params: Promise<{ slug: string }>; +} - useEffect(() => { - async function loadArtwork() { - try { - setLoading(true); - const data = await getArtwork(slug); - setArtwork(data); +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + try { + const artwork = await getArtwork(slug); + return { + title: artwork.title, + description: artwork.description || `${artwork.title} by Katheryn Trenshaw`, + }; + } catch { + return { title: 'Artwork Not Found' }; + } +} - // Fetch related works - const allWorks = await getArtworks({ status: 'published', limit: 4 }); - setRelatedWorks(allWorks.filter((w) => w.id !== data.id).slice(0, 3)); - } catch (err) { - setError('Artwork not found'); - console.error(err); - } finally { - setLoading(false); - } - } - loadArtwork(); - }, [slug]); +export default async function ArtworkDetailPage({ params }: PageProps) { + const { slug } = await params; - const handleAddToCart = () => { - if (artwork && isAvailable && !inCart) { - addItem(artwork); - } - }; - - if (loading) { - return ( -
-
Loading...
-
- ); + let artwork; + try { + artwork = await getArtwork(slug); + } catch { + notFound(); } - if (error || !artwork) { - return ( -
-

Artwork not found

- - Back to Store - -
- ); + // Fetch related works server-side + let relatedWorks: Artwork[] = []; + try { + const allWorks = await getArtworks({ status: 'published', limit: 4 }); + relatedWorks = allWorks.filter((w) => w.id !== artwork.id).slice(0, 3); + } catch { + relatedWorks = []; } + // Pre-compute image URLs server-side (ensures token is always available) const mainImageUrl = getAssetUrl(artwork.image, { width: 1200, quality: 90, format: 'webp' }); - const galleryImages = artwork.gallery || []; + const thumbnailUrl = getAssetUrl(artwork.image, { width: 200 }); + const galleryImageUrls = (artwork.gallery || []).map((img) => + getAssetUrl(img, { width: 200 }) + ); return ( -
- {/* Breadcrumb */} -
-
- -
-
- - {/* Main content */} -
-
- {/* Images */} -
-
- {artwork.image ? ( - {artwork.title} - ) : ( -
- No image available -
- )} - {isSold && ( -
- - Sold - -
- )} -
- - {/* Thumbnail gallery */} - {galleryImages.length > 0 && ( -
- - {galleryImages.map((img, index) => ( - - ))} -
- )} -
- - {/* Details */} -
-

{artwork.title}

- - {artwork.year && ( -

{artwork.year}

- )} - - {/* Price */} -
- {isSold ? ( -

Sold

- ) : artwork.price ? ( -

{artwork.currency === 'GBP' ? '£' : '$'}{artwork.price.toLocaleString()}

- ) : ( -

Price on request

- )} -
- - {/* Add to cart */} - {isAvailable && !isSold && ( -
- -
- )} - - {/* Enquire button for sold or POA items */} - {(isSold || !artwork.price) && ( -
- - Enquire About This Work - -
- )} - - {/* Details */} -
- {artwork.medium && ( -
- Medium - {artwork.medium} -
- )} - {artwork.dimensions && ( -
- Dimensions - {artwork.dimensions} -
- )} - {artwork.series && typeof artwork.series === 'object' && ( -
- Series - - {artwork.series.name} - -
- )} -
- - {/* Description */} - {artwork.description && ( -
-

- About This Work -

-
-
- )} - - {/* Shipping info */} -
-
- - - -
-

Secure Shipping

-

- All artworks are carefully packaged and shipped with insurance. -

-
-
-
-
-
-
- - {/* Related works */} - {relatedWorks.length > 0 && ( -
-
-

You May Also Like

-
- {relatedWorks.map((work) => ( - -
- {work.image && ( - {work.title} - )} -
-
-

{work.title}

- {work.price && ( -

{work.currency === 'GBP' ? '£' : '$'}{work.price.toLocaleString()}

- )} -
- - ))} -
-
-
- )} -
+ ); } diff --git a/frontend/src/lib/directus.ts b/frontend/src/lib/directus.ts index 79ee202..57ca08b 100644 --- a/frontend/src/lib/directus.ts +++ b/frontend/src/lib/directus.ts @@ -180,15 +180,23 @@ export interface DirectusFile { } // Create the Directus client (untyped to avoid complex SDK type inference) -const directusUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms.jeffemmett.com'; +// Use internal Docker URL for server-side API calls (bypasses Cloudflare Access) +const publicUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms.jeffemmett.com'; +const directusUrl = process.env.DIRECTUS_INTERNAL_URL || publicUrl; const apiToken = process.env.DIRECTUS_API_TOKEN || ''; +// For asset URLs: use internal Docker URL to bypass Cloudflare Access, +// and use NEXT_PUBLIC_ token so it's available in client components +const assetBaseUrl = process.env.NEXT_PUBLIC_DIRECTUS_ASSET_URL || publicUrl; +const assetToken = process.env.NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN || apiToken; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const directus = createDirectus(directusUrl) .with(staticToken(apiToken)) .with(rest()); // Helper to get asset URL (includes auth token since Directus assets require authentication) +// Uses internal Docker URL when available to bypass Cloudflare Access export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: { width?: number; height?: number; quality?: number; format?: 'webp' | 'jpg' | 'png' }): string { if (!fileId) return '/placeholder.jpg'; @@ -196,7 +204,7 @@ export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: const params = new URLSearchParams(); // Auth token required for asset access - if (apiToken) params.append('access_token', apiToken); + if (assetToken) params.append('access_token', assetToken); if (options) { if (options.width) params.append('width', options.width.toString()); @@ -205,7 +213,7 @@ export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: if (options.format) params.append('format', options.format); } - return `${directusUrl}/assets/${id}?${params.toString()}`; + return `${assetBaseUrl}/assets/${id}?${params.toString()}`; } // Map Directus artwork fields to frontend Artwork interface