Files
wijtransferen/frontend/src/components/gallery/ClientGallery.tsx
T
2026-08-22 02:59:16 +02:00

378 lines
12 KiB
TypeScript

import { useEffect, useState, type CSSProperties } from 'react';
import { AnimatePresence } from 'framer-motion';
import {
downloadMedia,
favoriteMedia,
getDownloadAllStatus,
startDownloadAll,
} from '../../lib/api';
import { formatDuration } from '../../lib/format';
import type { DownloadJob, MediaItem, PublicGallery } from '../../types/gallery';
import { PhotoViewer } from './PhotoViewer';
interface ClientGalleryProps {
gallery: PublicGallery;
}
export function ClientGallery({ gallery }: ClientGalleryProps) {
const [items, setItems] = useState(gallery.media);
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
const [downloadJob, setDownloadJob] = useState<DownloadJob | null>(null);
const photos = items.filter((item) => item.mimeType.startsWith('image/'));
const cover = gallery.cover || photos[0];
const theme = gallery.themeConfig || {};
const branding = gallery.brandingConfig || {};
const style = { '--gallery-accent': theme.accent || '#a85e55' } as CSSProperties;
useEffect(() => setItems(gallery.media), [gallery.media]);
useEffect(() => {
if (!downloadJob || !['QUEUED', 'PROCESSING'].includes(downloadJob.status)) return undefined;
const timer = window.setTimeout(async () => {
try {
const updated = await getDownloadAllStatus(gallery.slug, downloadJob.jobId);
setDownloadJob(updated);
} catch (reason) {
setDownloadJob((current) =>
current
? {
...current,
status: 'FAILED',
error: reason instanceof Error ? reason.message : 'Download failed.',
}
: current,
);
}
}, 1300);
return () => window.clearTimeout(timer);
}, [downloadJob, gallery.slug]);
async function toggleFavorite(item: MediaItem) {
if (gallery.favoritesEnabled === false) return;
const next = !item.favorited;
setItems((current) =>
current.map((candidate) =>
candidate.id === item.id ? { ...candidate, favorited: next } : candidate,
),
);
try {
await favoriteMedia(gallery.slug, item.id, next);
} catch {
setItems((current) =>
current.map((candidate) =>
candidate.id === item.id ? { ...candidate, favorited: !next } : candidate,
),
);
}
}
async function downloadOne(item: MediaItem) {
try {
const url = await downloadMedia(gallery.slug, item.id);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = item.originalFilename;
anchor.target = '_blank';
anchor.rel = 'noreferrer';
anchor.click();
} catch {
// The client gallery stays usable if a single signed URL cannot be issued.
}
}
async function downloadAll() {
if (downloadJob?.status === 'QUEUED' || downloadJob?.status === 'PROCESSING') return;
try {
setDownloadJob(await startDownloadAll(gallery.slug));
} catch (reason) {
setDownloadJob({
jobId: '',
status: 'FAILED',
error: reason instanceof Error ? reason.message : 'Download failed.',
});
}
}
function openPhoto(item: MediaItem) {
const index = photos.findIndex((photo) => photo.id === item.id);
setViewerIndex(index >= 0 ? index : null);
}
const mode = theme.mode === 'dark' ? 'dark' : 'light';
const layout = theme.layout || 'editorial';
return (
<div
className={`client-gallery client-gallery--${mode} client-gallery--${layout}`}
style={style}
>
{gallery.preview && (
<div className="preview-ribbon">
<span>Preview mode</span>
<span>This is how your clients will see it</span>
</div>
)}
<header className="client-gallery__nav">
<a
className="client-brand"
href={branding.websiteUrl || '#'}
onClick={(event) => {
if (!branding.websiteUrl) event.preventDefault();
}}
>
{branding.logoUrl ? (
<img src={branding.logoUrl} alt="" />
) : (
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
)}
<span>{branding.studioName || 'Your studio'}</span>
</a>
<div className="client-gallery__nav-actions">
{gallery.downloadAllEnabled !== false && gallery.downloadsEnabled !== false && (
<button type="button" className="client-nav-button" onClick={() => void downloadAll()}>
{downloadJob?.status === 'PROCESSING'
? 'Preparing ZIP...'
: downloadJob?.status === 'READY'
? 'ZIP ready'
: 'Download gallery'}{' '}
<span aria-hidden="true">&#8595;</span>
</button>
)}
<span className="client-gallery__edition">{gallery.clientName}</span>
</div>
</header>
<main>
<section className="client-hero">
<div className="client-hero__copy">
<p className="client-kicker">A collection for {gallery.clientName}</p>
<h1 className="client-display">{gallery.title}</h1>
{gallery.description && (
<p className="client-hero__description">{gallery.description}</p>
)}
</div>
<div className="client-hero__cover">
{cover?.previewUrl ? (
<img src={cover.previewUrl} alt="" />
) : (
<div className="client-hero__fallback">
<span>{gallery.clientName.slice(0, 1)}</span>
</div>
)}
<div className="client-hero__cover-caption">
<span>Open to remember</span>
<span>{items.length} pieces</span>
</div>
</div>
<div className="client-hero__scroll">
<span /> Scroll to wander
</div>
</section>
<section className="client-work-section">
<div className="client-section-heading">
<div>
<p className="client-kicker">The collection</p>
<h2 className="client-display">
The day, <em>held still.</em>
</h2>
</div>
<span>
{photos.length} photographs /{' '}
{items.filter((item) => item.mimeType.startsWith('video/')).length} films
</span>
</div>
{items.length === 0 ? (
<div className="client-empty">
<span>+</span>
<p>Your gallery is still being arranged.</p>
</div>
) : (
<div className="client-media-grid">
{items.map((item) => (
<MediaCard
key={item.id}
item={item}
gallery={gallery}
onOpen={() => openPhoto(item)}
onFavorite={() => void toggleFavorite(item)}
onDownload={() => void downloadOne(item)}
/>
))}
</div>
)}
</section>
<section className="client-download-section">
<div className="client-download-section__copy">
<p className="client-kicker">Take it with you</p>
<h2 className="client-display">
The moments are
<br />
<em>yours to keep.</em>
</h2>
<p>Save the full-resolution photographs and revisit this chapter whenever you like.</p>
</div>
{gallery.downloadsEnabled !== false && (
<button
className="client-download-button"
type="button"
onClick={() => void downloadAll()}
disabled={downloadJob?.status === 'PROCESSING' || downloadJob?.status === 'QUEUED'}
>
<span>
{downloadJob?.status === 'PROCESSING'
? 'Preparing your gallery'
: downloadJob?.status === 'READY'
? 'Download ready'
: 'Download all originals'}
</span>
<span aria-hidden="true">&#8595;</span>
</button>
)}
{downloadJob?.status === 'READY' && downloadJob.url && (
<a
className="client-download-ready"
href={downloadJob.url}
target="_blank"
rel="noreferrer"
>
Your ZIP is ready <span aria-hidden="true">&#8599;</span>
</a>
)}
{downloadJob?.status === 'FAILED' && (
<p className="client-download-error">
{downloadJob.error || 'The download could not be prepared.'}
</p>
)}
</section>
</main>
<footer className="client-footer">
<div>
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
<div>
<strong>{branding.studioName || 'Your studio'}</strong>
<small>{branding.tagline || 'Photographs for keeps.'}</small>
</div>
</div>
<div className="client-footer__links">
{branding.websiteUrl && (
<a href={branding.websiteUrl} target="_blank" rel="noreferrer">
Website &#8599;
</a>
)}
{branding.instagramUrl && (
<a href={branding.instagramUrl} target="_blank" rel="noreferrer">
Instagram &#8599;
</a>
)}
</div>
<span className="client-footer__credit">Delivered with intention</span>
</footer>
<AnimatePresence>
{viewerIndex !== null && (
<PhotoViewer
items={photos}
index={viewerIndex}
onClose={() => setViewerIndex(null)}
onChange={setViewerIndex}
onFavorite={(item) => void toggleFavorite(item)}
onDownload={(item) => void downloadOne(item)}
/>
)}
</AnimatePresence>
</div>
);
}
function MediaCard({
item,
gallery,
onOpen,
onFavorite,
onDownload,
}: {
item: MediaItem;
gallery: PublicGallery;
onOpen: () => void;
onFavorite: () => void;
onDownload: () => void;
}) {
const isVideo = item.mimeType.startsWith('video/');
const ready = item.processingStatus === 'READY';
return (
<article
className={`client-media-card ${isVideo ? 'client-media-card--video' : ''} ${gallery.watermarkEnabled ? 'client-media-card--watermarked' : ''}`}
>
{isVideo ? (
<div className="client-media-card__video-wrap">
{ready && item.previewUrl ? (
<video
controls
playsInline
preload="metadata"
poster={gallery.cover?.previewUrl}
src={item.previewUrl}
/>
) : (
<div className="client-media-card__processing">
{item.processingStatus.toLowerCase()}
</div>
)}
<span className="client-media-card__video-label">
Film {formatDuration(item.durationSeconds)}
</span>
</div>
) : (
<button
className="client-media-card__image"
type="button"
onClick={onOpen}
disabled={!ready}
>
{ready && (item.thumbnailUrl || item.previewUrl) ? (
<img
src={item.thumbnailUrl || item.previewUrl}
alt={item.originalFilename}
loading="lazy"
/>
) : (
<div className="client-media-card__processing">
{item.processingStatus.toLowerCase()}
</div>
)}
{gallery.watermarkEnabled && (
<span className="client-watermark">
{gallery.brandingConfig.studioName || 'Preview'}
</span>
)}
<span className="client-media-card__open" aria-hidden="true">
&#8599;
</span>
</button>
)}
<div className="client-media-card__footer">
<span>{item.originalFilename}</span>
{gallery.favoritesEnabled !== false && (
<button
type="button"
className={item.favorited ? 'is-favorited' : ''}
onClick={onFavorite}
aria-label={item.favorited ? 'Remove favorite' : 'Favorite photo'}
>
{item.favorited ? '♥' : '♡'}
</button>
)}
{gallery.downloadsEnabled !== false && (
<button type="button" onClick={onDownload} aria-label="Download original">
&#8595;
</button>
)}
</div>
</article>
);
}