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 <noreply@anthropic.com>
This commit is contained in:
parent
808b552b8e
commit
41d784e92d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-white pt-24">
|
||||
{/* Breadcrumb */}
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4">
|
||||
<nav className="text-sm text-gray-500">
|
||||
<Link href="/" className="hover:text-gray-900">Home</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<Link href="/store" className="hover:text-gray-900">Store</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-gray-900">{artwork.title}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||
<div className="grid gap-12 lg:grid-cols-2">
|
||||
{/* Images */}
|
||||
<div>
|
||||
<div className="relative aspect-[4/5] bg-gray-100">
|
||||
{artwork.image ? (
|
||||
<Image
|
||||
src={mainImageUrl}
|
||||
alt={artwork.title}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
No image available
|
||||
</div>
|
||||
)}
|
||||
{isSold && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<span className="bg-white px-6 py-2 text-sm font-medium uppercase tracking-wider">
|
||||
Sold
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail gallery */}
|
||||
{galleryImages.length > 0 && (
|
||||
<div className="mt-4 flex gap-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setSelectedImage(0)}
|
||||
className={`relative h-20 w-20 flex-shrink-0 bg-gray-100 ${
|
||||
selectedImage === 0 ? 'ring-2 ring-gray-900' : ''
|
||||
}`}
|
||||
>
|
||||
{artwork.image && (
|
||||
<Image
|
||||
src={thumbnailUrl}
|
||||
alt={artwork.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{galleryImageUrls.map((url, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(index + 1)}
|
||||
className={`relative h-20 w-20 flex-shrink-0 bg-gray-100 ${
|
||||
selectedImage === index + 1 ? 'ring-2 ring-gray-900' : ''
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={url}
|
||||
alt={`${artwork.title} - Image ${index + 2}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="lg:py-8">
|
||||
<h1 className="font-serif text-3xl md:text-4xl">{artwork.title}</h1>
|
||||
|
||||
{artwork.year && (
|
||||
<p className="mt-2 text-gray-500">{artwork.year}</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="mt-6">
|
||||
{isSold ? (
|
||||
<p className="text-xl font-medium text-gray-400">Sold</p>
|
||||
) : artwork.price ? (
|
||||
<p className="text-2xl font-medium">{artwork.currency === 'GBP' ? '£' : '$'}{artwork.price.toLocaleString()}</p>
|
||||
) : (
|
||||
<p className="text-lg text-gray-600">Price on request</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add to cart */}
|
||||
{isAvailable && !isSold && (
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={inCart}
|
||||
className={`w-full py-4 text-center text-sm font-medium uppercase tracking-wider transition ${
|
||||
inCart
|
||||
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-gray-900 text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{inCart ? 'Added to Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enquire button for sold or POA items */}
|
||||
{(isSold || !artwork.price) && (
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href={`/contact?artwork=${encodeURIComponent(artwork.title)}`}
|
||||
className="block w-full py-4 text-center text-sm font-medium uppercase tracking-wider border border-gray-900 text-gray-900 hover:bg-gray-900 hover:text-white transition"
|
||||
>
|
||||
Enquire About This Work
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="mt-8 space-y-4 border-t border-gray-200 pt-8">
|
||||
{artwork.medium && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Medium</span>
|
||||
<span className="text-gray-900">{artwork.medium}</span>
|
||||
</div>
|
||||
)}
|
||||
{artwork.dimensions && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Dimensions</span>
|
||||
<span className="text-gray-900">{artwork.dimensions}</span>
|
||||
</div>
|
||||
)}
|
||||
{artwork.series && typeof artwork.series === 'object' && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Series</span>
|
||||
<Link
|
||||
href={`/gallery/series/${artwork.series.slug || artwork.series.id}`}
|
||||
className="text-gray-900 underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
{artwork.series.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{artwork.description && (
|
||||
<div className="mt-8 border-t border-gray-200 pt-8">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-gray-500">
|
||||
About This Work
|
||||
</h3>
|
||||
<div
|
||||
className="mt-4 prose prose-sm text-gray-600"
|
||||
dangerouslySetInnerHTML={{ __html: artwork.description }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping info */}
|
||||
<div className="mt-8 border-t border-gray-200 pt-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<svg className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Secure Shipping</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
All artworks are carefully packaged and shipped with insurance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related works */}
|
||||
{relatedWorks.length > 0 && (
|
||||
<div className="border-t border-gray-200 bg-gray-50">
|
||||
<div className="mx-auto max-w-7xl px-4 py-16">
|
||||
<h2 className="font-serif text-2xl text-center mb-12">You May Also Like</h2>
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{relatedWorks.map((work) => (
|
||||
<Link key={work.id} href={`/store/${work.slug || work.id}`} className="group">
|
||||
<div className="img-zoom relative aspect-[4/5] bg-gray-100">
|
||||
{work.image && (
|
||||
<Image
|
||||
src={getAssetUrl(work.image, { width: 600, quality: 85, format: 'webp' })}
|
||||
alt={work.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<h3 className="font-serif text-lg">{work.title}</h3>
|
||||
{work.price && (
|
||||
<p className="mt-1 text-sm">{work.currency === 'GBP' ? '£' : '$'}{work.price.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Artwork | null>(null);
|
||||
const [relatedWorks, setRelatedWorks] = useState<Artwork[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getArtwork(slug);
|
||||
setArtwork(data);
|
||||
|
||||
// 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]);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (artwork && isAvailable && !inCart) {
|
||||
addItem(artwork);
|
||||
}
|
||||
const artwork = await getArtwork(slug);
|
||||
return {
|
||||
title: artwork.title,
|
||||
description: artwork.description || `${artwork.title} by Katheryn Trenshaw`,
|
||||
};
|
||||
} catch {
|
||||
return { title: 'Artwork Not Found' };
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center pt-24">
|
||||
<div className="animate-pulse text-gray-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
export default async function ArtworkDetailPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
let artwork;
|
||||
try {
|
||||
artwork = await getArtwork(slug);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (error || !artwork) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center pt-24">
|
||||
<h1 className="font-serif text-2xl">Artwork not found</h1>
|
||||
<Link href="/store" className="mt-4 text-sm underline">
|
||||
Back to Store
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
// 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 (
|
||||
<div className="min-h-screen bg-white pt-24">
|
||||
{/* Breadcrumb */}
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4">
|
||||
<nav className="text-sm text-gray-500">
|
||||
<Link href="/" className="hover:text-gray-900">Home</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<Link href="/store" className="hover:text-gray-900">Store</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-gray-900">{artwork.title}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||
<div className="grid gap-12 lg:grid-cols-2">
|
||||
{/* Images */}
|
||||
<div>
|
||||
<div className="relative aspect-[4/5] bg-gray-100">
|
||||
{artwork.image ? (
|
||||
<Image
|
||||
src={mainImageUrl}
|
||||
alt={artwork.title}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
priority
|
||||
<ArtworkDetailClient
|
||||
artwork={artwork}
|
||||
relatedWorks={relatedWorks}
|
||||
mainImageUrl={mainImageUrl}
|
||||
galleryImageUrls={galleryImageUrls}
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
No image available
|
||||
</div>
|
||||
)}
|
||||
{isSold && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<span className="bg-white px-6 py-2 text-sm font-medium uppercase tracking-wider">
|
||||
Sold
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail gallery */}
|
||||
{galleryImages.length > 0 && (
|
||||
<div className="mt-4 flex gap-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setSelectedImage(0)}
|
||||
className={`relative h-20 w-20 flex-shrink-0 bg-gray-100 ${
|
||||
selectedImage === 0 ? 'ring-2 ring-gray-900' : ''
|
||||
}`}
|
||||
>
|
||||
{artwork.image && (
|
||||
<Image
|
||||
src={getAssetUrl(artwork.image, { width: 200 })}
|
||||
alt={artwork.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{galleryImages.map((img, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(index + 1)}
|
||||
className={`relative h-20 w-20 flex-shrink-0 bg-gray-100 ${
|
||||
selectedImage === index + 1 ? 'ring-2 ring-gray-900' : ''
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={getAssetUrl(img, { width: 200 })}
|
||||
alt={`${artwork.title} - Image ${index + 2}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="lg:py-8">
|
||||
<h1 className="font-serif text-3xl md:text-4xl">{artwork.title}</h1>
|
||||
|
||||
{artwork.year && (
|
||||
<p className="mt-2 text-gray-500">{artwork.year}</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="mt-6">
|
||||
{isSold ? (
|
||||
<p className="text-xl font-medium text-gray-400">Sold</p>
|
||||
) : artwork.price ? (
|
||||
<p className="text-2xl font-medium">{artwork.currency === 'GBP' ? '£' : '$'}{artwork.price.toLocaleString()}</p>
|
||||
) : (
|
||||
<p className="text-lg text-gray-600">Price on request</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add to cart */}
|
||||
{isAvailable && !isSold && (
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={inCart}
|
||||
className={`w-full py-4 text-center text-sm font-medium uppercase tracking-wider transition ${
|
||||
inCart
|
||||
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-gray-900 text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{inCart ? 'Added to Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enquire button for sold or POA items */}
|
||||
{(isSold || !artwork.price) && (
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href={`/contact?artwork=${encodeURIComponent(artwork.title)}`}
|
||||
className="block w-full py-4 text-center text-sm font-medium uppercase tracking-wider border border-gray-900 text-gray-900 hover:bg-gray-900 hover:text-white transition"
|
||||
>
|
||||
Enquire About This Work
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="mt-8 space-y-4 border-t border-gray-200 pt-8">
|
||||
{artwork.medium && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Medium</span>
|
||||
<span className="text-gray-900">{artwork.medium}</span>
|
||||
</div>
|
||||
)}
|
||||
{artwork.dimensions && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Dimensions</span>
|
||||
<span className="text-gray-900">{artwork.dimensions}</span>
|
||||
</div>
|
||||
)}
|
||||
{artwork.series && typeof artwork.series === 'object' && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Series</span>
|
||||
<Link
|
||||
href={`/gallery/series/${artwork.series.slug || artwork.series.id}`}
|
||||
className="text-gray-900 underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
{artwork.series.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{artwork.description && (
|
||||
<div className="mt-8 border-t border-gray-200 pt-8">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-gray-500">
|
||||
About This Work
|
||||
</h3>
|
||||
<div
|
||||
className="mt-4 prose prose-sm text-gray-600"
|
||||
dangerouslySetInnerHTML={{ __html: artwork.description }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping info */}
|
||||
<div className="mt-8 border-t border-gray-200 pt-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<svg className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Secure Shipping</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
All artworks are carefully packaged and shipped with insurance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related works */}
|
||||
{relatedWorks.length > 0 && (
|
||||
<div className="border-t border-gray-200 bg-gray-50">
|
||||
<div className="mx-auto max-w-7xl px-4 py-16">
|
||||
<h2 className="font-serif text-2xl text-center mb-12">You May Also Like</h2>
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{relatedWorks.map((work) => (
|
||||
<Link key={work.id} href={`/store/${work.slug || work.id}`} className="group">
|
||||
<div className="img-zoom relative aspect-[4/5] bg-gray-100">
|
||||
{work.image && (
|
||||
<Image
|
||||
src={getAssetUrl(work.image, { width: 600, quality: 85, format: 'webp' })}
|
||||
alt={work.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<h3 className="font-serif text-lg">{work.title}</h3>
|
||||
{work.price && (
|
||||
<p className="mt-1 text-sm">{work.currency === 'GBP' ? '£' : '$'}{work.price.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<any>(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
|
||||
|
|
|
|||
Loading…
Reference in New Issue