378 lines
12 KiB
TypeScript
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">↓</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">↓</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">↗</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 ↗
|
|
</a>
|
|
)}
|
|
{branding.instagramUrl && (
|
|
<a href={branding.instagramUrl} target="_blank" rel="noreferrer">
|
|
Instagram ↗
|
|
</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">
|
|
↗
|
|
</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">
|
|
↓
|
|
</button>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|