init
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user