init
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { RequireAuth } from './components/app/RequireAuth';
|
||||
import { AuthProvider } from './features/auth/AuthContext';
|
||||
import GalleryPreviewPage from './pages/GalleryPreviewPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import PublicGalleryPage from './pages/PublicGalleryPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import GalleriesPage from './pages/dashboard/GalleriesPage';
|
||||
import GalleryEditorPage from './pages/dashboard/GalleryEditorPage';
|
||||
import PlaceholderPage from './pages/dashboard/PlaceholderPage';
|
||||
import DevPage from './pages/dashboard/DevPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/g/:slug" element={<PublicGalleryPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/preview/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<GalleryPreviewPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DashboardPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/galleries"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<GalleriesPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/galleries/new"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<GalleryEditorPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/galleries/:id/edit"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<GalleryEditorPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/storage"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<PlaceholderPage
|
||||
title="Storage"
|
||||
description="A calm view of every original, preview, and byte in your studio is on its way."
|
||||
/>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<PlaceholderPage
|
||||
title="Settings"
|
||||
description="Your studio identity and delivery defaults will have a home here soon."
|
||||
/>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/dev"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DevPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function AppLoading() {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<span className="app-loading__mark">N</span>
|
||||
<span className="app-loading__line" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function AuthLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="auth-shell">
|
||||
<div className="auth-shell__texture" aria-hidden="true" />
|
||||
<div className="auth-shell__brand">
|
||||
<span className="platform-mark">N</span>
|
||||
<span>Northline</span>
|
||||
</div>
|
||||
<div className="auth-shell__aside">
|
||||
<p className="platform-kicker">The work deserves a beautiful handoff.</p>
|
||||
<h1 className="platform-display">
|
||||
Deliver the
|
||||
<br />
|
||||
<em>feeling.</em>
|
||||
</h1>
|
||||
<p>Private galleries for the photographs people keep forever.</p>
|
||||
</div>
|
||||
<div className="auth-shell__panel">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { AppLoading } from './AppLoading';
|
||||
import { useAuth } from '../../features/auth/useAuth';
|
||||
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NavLink, Link } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useAuth } from '../../features/auth/useAuth';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Overview', path: '/dashboard', end: true },
|
||||
{ label: 'Galleries', path: '/dashboard/galleries', end: false },
|
||||
{ label: 'Storage', path: '/dashboard/storage', end: false },
|
||||
{ label: 'Settings', path: '/dashboard/settings', end: false },
|
||||
{ label: 'Dev', path: '/dashboard/dev', end: false },
|
||||
];
|
||||
|
||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const { user, signOut } = useAuth();
|
||||
const initials = user?.name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="studio-shell">
|
||||
<aside className="studio-sidebar">
|
||||
<Link className="studio-logo" to="/dashboard">
|
||||
<span className="studio-logo__mark">N</span>
|
||||
<span>
|
||||
Northline
|
||||
<small>delivery studio</small>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="studio-sidebar__label">Workspace</div>
|
||||
<nav className="studio-nav" aria-label="Main navigation">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
className={({ isActive }) => `studio-nav__item ${isActive ? 'is-active' : ''}`}
|
||||
end={item.end}
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
>
|
||||
<span className="studio-nav__dot" aria-hidden="true" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="studio-sidebar__bottom">
|
||||
<div className="studio-sidebar__note">
|
||||
<span className="studio-sidebar__note-mark">+</span>
|
||||
<span>
|
||||
<strong>Make it memorable.</strong>
|
||||
<small>Your work deserves a proper handoff.</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="studio-account">
|
||||
<span className="studio-account__avatar">{initials || 'N'}</span>
|
||||
<span className="studio-account__details">
|
||||
<strong>{user?.name}</strong>
|
||||
<small>{user?.email}</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
aria-label="Sign out"
|
||||
className="studio-account__logout"
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="studio-main">
|
||||
<div className="studio-mobilebar">
|
||||
<Link className="studio-logo" to="/dashboard">
|
||||
<span className="studio-logo__mark">N</span>
|
||||
<span>Northline</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-mobilebar__account"
|
||||
onClick={() => void signOut()}
|
||||
>
|
||||
{initials || 'N'}
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { GallerySummary } from '../../types/gallery';
|
||||
import { formatBytes, formatDate } from '../../lib/format';
|
||||
|
||||
interface GalleryCardProps {
|
||||
gallery: GallerySummary;
|
||||
onDelete: (gallery: GallerySummary) => void;
|
||||
}
|
||||
|
||||
export function GalleryCard({ gallery, onDelete }: GalleryCardProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const clientURL = `${window.location.origin}/g/${gallery.slug}`;
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(clientURL);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="gallery-card">
|
||||
<Link className="gallery-card__cover" to={`/dashboard/galleries/${gallery.id}/edit`}>
|
||||
{gallery.coverUrl ? (
|
||||
<img src={gallery.coverUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="gallery-card__cover-placeholder" aria-hidden="true">
|
||||
<span>{gallery.clientName.slice(0, 1).toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`status-pill status-pill--${gallery.status}`}>
|
||||
<i aria-hidden="true" /> {gallery.status}
|
||||
</span>
|
||||
<span className="gallery-card__cover-arrow" aria-hidden="true">
|
||||
↗
|
||||
</span>
|
||||
</Link>
|
||||
<div className="gallery-card__body">
|
||||
<div className="gallery-card__heading">
|
||||
<div>
|
||||
<p>{gallery.clientName}</p>
|
||||
<h3>{gallery.title}</h3>
|
||||
</div>
|
||||
<span className="gallery-card__date">{formatDate(gallery.createdAt)}</span>
|
||||
</div>
|
||||
<div className="gallery-card__meta">
|
||||
<span>{gallery.photoCount} photos</span>
|
||||
<span>{gallery.videoCount} videos</span>
|
||||
<span>{formatBytes(gallery.totalBytes)}</span>
|
||||
</div>
|
||||
<div className="gallery-card__actions">
|
||||
<Link className="text-action" to={`/dashboard/galleries/${gallery.id}/edit`}>
|
||||
Edit <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
<Link className="text-action text-action--muted" to={`/preview/${gallery.id}`}>
|
||||
Preview
|
||||
</Link>
|
||||
{gallery.status === 'published' && (
|
||||
<button
|
||||
className="text-action text-action--muted"
|
||||
type="button"
|
||||
onClick={() => void copyLink()}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="gallery-card__delete"
|
||||
type="button"
|
||||
onClick={() => onDelete(gallery)}
|
||||
aria-label={`Delete ${gallery.title}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface StudioStatProps {
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
accent?: 'coral' | 'violet' | 'gold' | 'ink';
|
||||
}
|
||||
|
||||
export function StudioStat({ label, value, note, accent = 'coral' }: StudioStatProps) {
|
||||
return (
|
||||
<div className={`studio-stat studio-stat--${accent}`}>
|
||||
<div className="studio-stat__topline">
|
||||
<span>{label}</span>
|
||||
<i aria-hidden="true" />
|
||||
</div>
|
||||
<strong>{value}</strong>
|
||||
<small>{note}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { completeUpload, createUpload, uploadToStorage } from '../../lib/api';
|
||||
import { formatBytes } from '../../lib/format';
|
||||
import type { MediaItem } from '../../types/gallery';
|
||||
|
||||
type UploadStatus = 'queued' | 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled';
|
||||
|
||||
interface UploadEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
progress: number;
|
||||
status: UploadStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UploadDropzoneProps {
|
||||
galleryId: string;
|
||||
onMedia: (media: MediaItem) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzoneProps) {
|
||||
const [entries, setEntries] = useState<UploadEntry[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const controllers = useRef(new Map<string, AbortController>());
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function addFiles(files: File[]) {
|
||||
files.forEach((file, index) => {
|
||||
const id = `${file.name}-${file.lastModified}-${Date.now()}-${index}`;
|
||||
setEntries((current) => [...current, { id, file, progress: 0, status: 'queued' }]);
|
||||
void upload(id, file);
|
||||
});
|
||||
}
|
||||
|
||||
async function upload(id: string, file: File) {
|
||||
const controller = new AbortController();
|
||||
controllers.current.set(id, controller);
|
||||
try {
|
||||
setEntry(id, { status: 'uploading', progress: 0 });
|
||||
const created = await createUpload(galleryId, file);
|
||||
await uploadToStorage(
|
||||
created.uploadUrl,
|
||||
file,
|
||||
(progress) => setEntry(id, { progress }),
|
||||
controller.signal,
|
||||
);
|
||||
setEntry(id, { status: 'processing', progress: 100 });
|
||||
const completed = await completeUpload(created.uploadId);
|
||||
setEntry(id, {
|
||||
status: completed.processingStatus === 'READY' ? 'ready' : 'processing',
|
||||
progress: 100,
|
||||
});
|
||||
onMedia(completed);
|
||||
window.setTimeout(onRefresh, 1400);
|
||||
} catch (reason) {
|
||||
if (reason instanceof DOMException && reason.name === 'AbortError') {
|
||||
setEntry(id, { status: 'cancelled' });
|
||||
} else {
|
||||
const message = reason instanceof Error ? reason.message : 'Upload failed.';
|
||||
setEntry(id, {
|
||||
status: 'failed',
|
||||
error: message,
|
||||
});
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
'northline:last-upload-error',
|
||||
JSON.stringify({ at: new Date().toISOString(), filename: file.name, message }),
|
||||
);
|
||||
} catch {
|
||||
// Diagnostics should never make an upload failure worse.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
controllers.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function setEntry(id: string, update: Partial<UploadEntry>) {
|
||||
setEntries((current) =>
|
||||
current.map((entry) => (entry.id === id ? { ...entry, ...update } : entry)),
|
||||
);
|
||||
}
|
||||
|
||||
function cancel(id: string) {
|
||||
controllers.current.get(id)?.abort();
|
||||
}
|
||||
|
||||
function retry(entry: UploadEntry) {
|
||||
setEntry(entry.id, { status: 'queued', progress: 0, error: undefined });
|
||||
void upload(entry.id, entry.file);
|
||||
}
|
||||
|
||||
const totalProgress = entries.length
|
||||
? Math.round(entries.reduce((total, entry) => total + entry.progress, 0) / entries.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="upload-zone-wrap">
|
||||
<button
|
||||
className={`upload-zone ${dragging ? 'is-dragging' : ''}`}
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
addFiles(Array.from(event.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<span className="upload-zone__orb" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span className="upload-zone__title">Drop finished work here</span>
|
||||
<span className="upload-zone__hint">
|
||||
or click to browse / JPG, PNG, WEBP, HEIC, MP4, MOV
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,video/mp4,video/quicktime,video/webm"
|
||||
onChange={(event) => {
|
||||
addFiles(Array.from(event.target.files || []));
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{entries.length > 0 && (
|
||||
<div className="upload-queue">
|
||||
<div className="upload-queue__heading">
|
||||
<span>Upload queue</span>
|
||||
<strong>{totalProgress}% overall</strong>
|
||||
</div>
|
||||
<div className="upload-queue__track">
|
||||
<span style={{ width: `${totalProgress}%` }} />
|
||||
</div>
|
||||
<div className="upload-queue__items">
|
||||
{entries.map((entry) => (
|
||||
<div className="upload-row" key={entry.id}>
|
||||
<span className="upload-row__type">
|
||||
{entry.file.type.startsWith('video/') ? 'MOV' : 'IMG'}
|
||||
</span>
|
||||
<span className="upload-row__name">
|
||||
{entry.file.name}
|
||||
<small>{formatBytes(entry.file.size)}</small>
|
||||
</span>
|
||||
<span className={`upload-row__status upload-row__status--${entry.status}`}>
|
||||
{entry.status === 'uploading' ? `${entry.progress}%` : entry.status}
|
||||
</span>
|
||||
{entry.error && <small className="upload-row__error">{entry.error}</small>}
|
||||
{(entry.status === 'uploading' || entry.status === 'processing') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancel(entry.id)}
|
||||
aria-label={`Cancel ${entry.file.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{entry.status === 'failed' && (
|
||||
<button type="button" onClick={() => retry(entry)}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
import type { PublicGallery } from '../../types/gallery';
|
||||
|
||||
interface PasswordGateProps {
|
||||
gallery: PublicGallery;
|
||||
onUnlock: (password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await onUnlock(password);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'That password did not work.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="client-lock-screen">
|
||||
<div className="client-lock-screen__glow" aria-hidden="true" />
|
||||
<div className="client-lock-screen__brand">
|
||||
<span className="client-monogram">
|
||||
{gallery.brandingConfig.studioName?.slice(0, 1) || 'N'}
|
||||
</span>
|
||||
<span>{gallery.brandingConfig.studioName || 'Private gallery'}</span>
|
||||
</div>
|
||||
<div className="client-lock-screen__center">
|
||||
<span className="client-lock-screen__lock" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<p className="client-kicker">A private delivery</p>
|
||||
<h1 className="client-display">{gallery.title}</h1>
|
||||
<p>This gallery was made for {gallery.clientName}. Enter the password to open it.</p>
|
||||
<form className="client-password-form" onSubmit={submit}>
|
||||
<label className="sr-only" htmlFor="gallery-password">
|
||||
Gallery password
|
||||
</label>
|
||||
<input
|
||||
id="gallery-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
className="client-round-button"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
aria-label="Open gallery"
|
||||
>
|
||||
{submitting ? '...' : <span aria-hidden="true">↗</span>}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="client-form-error">{error}</p>}
|
||||
</div>
|
||||
<p className="client-lock-screen__footer">The work is waiting inside.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { formatBytes } from '../../lib/format';
|
||||
import type { MediaItem } from '../../types/gallery';
|
||||
|
||||
interface PhotoViewerProps {
|
||||
items: MediaItem[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onChange: (index: number) => void;
|
||||
onFavorite: (item: MediaItem) => void;
|
||||
onDownload: (item: MediaItem) => void;
|
||||
}
|
||||
|
||||
export function PhotoViewer({
|
||||
items,
|
||||
index,
|
||||
onClose,
|
||||
onChange,
|
||||
onFavorite,
|
||||
onDownload,
|
||||
}: PhotoViewerProps) {
|
||||
const startX = useRef<number | null>(null);
|
||||
const item = items[index];
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
[index - 1, index + 1].forEach((neighborIndex) => {
|
||||
const neighbor = items[neighborIndex];
|
||||
if (neighbor?.previewUrl) {
|
||||
const image = new Image();
|
||||
image.src = neighbor.previewUrl;
|
||||
}
|
||||
});
|
||||
}, [index, items]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
if (event.key === 'ArrowLeft' && index > 0) onChange(index - 1);
|
||||
if (event.key === 'ArrowRight' && index < items.length - 1) onChange(index + 1);
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [index, items.length, onChange, onClose]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="photo-viewer"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Photo viewer"
|
||||
>
|
||||
<div className="photo-viewer__topline">
|
||||
<span>{item.originalFilename}</span>
|
||||
<button type="button" onClick={onClose} aria-label="Close photo viewer">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="photo-viewer__stage"
|
||||
onTouchStart={(event) => {
|
||||
startX.current = event.touches[0]?.clientX ?? null;
|
||||
}}
|
||||
onTouchEnd={(event) => {
|
||||
if (startX.current === null) return;
|
||||
const distance = event.changedTouches[0].clientX - startX.current;
|
||||
if (Math.abs(distance) > 45)
|
||||
onChange(distance > 0 ? Math.max(0, index - 1) : Math.min(items.length - 1, index + 1));
|
||||
startX.current = null;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="photo-viewer__arrow photo-viewer__arrow--left"
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(0, index - 1))}
|
||||
disabled={index === 0}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
className="photo-viewer__image-wrap"
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 1.02 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{item.previewUrl ? (
|
||||
<img src={item.previewUrl} alt={item.originalFilename} draggable={false} />
|
||||
) : (
|
||||
<span>Preview processing</span>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<button
|
||||
className="photo-viewer__arrow photo-viewer__arrow--right"
|
||||
type="button"
|
||||
onClick={() => onChange(Math.min(items.length - 1, index + 1))}
|
||||
disabled={index === items.length - 1}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<div className="photo-viewer__bottomline">
|
||||
<span>
|
||||
{String(index + 1).padStart(2, '0')} / {String(items.length).padStart(2, '0')}{' '}
|
||||
<small>{formatBytes(item.fileSize)}</small>
|
||||
</span>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className={item.favorited ? 'is-favorited' : ''}
|
||||
onClick={() => onFavorite(item)}
|
||||
>
|
||||
{item.favorited ? '♥' : '♡'} <span>{item.favorited ? 'Favorited' : 'Favorite'}</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => onDownload(item)}>
|
||||
↓ <span>Download</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type BackgroundTone = 'intro' | 'reveal' | 'content' | 'finish';
|
||||
|
||||
interface BackgroundProps {
|
||||
children: ReactNode;
|
||||
tone?: BackgroundTone;
|
||||
}
|
||||
|
||||
export function Background({ children, tone = 'intro' }: BackgroundProps) {
|
||||
return (
|
||||
<div className={`gift-background gift-background--${tone}`}>
|
||||
<div className="gift-background__wash" aria-hidden="true" />
|
||||
<div className="gift-background__orb gift-background__orb--one" aria-hidden="true" />
|
||||
<div className="gift-background__orb gift-background__orb--two" aria-hidden="true" />
|
||||
<div className="gift-background__grid" aria-hidden="true" />
|
||||
<div className="gift-background__noise" aria-hidden="true" />
|
||||
<main className="gift-background__content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const pieces = [
|
||||
{ left: 8, top: -6, size: 9, delay: 0, color: '#ed9a79', rotation: 18 },
|
||||
{ left: 19, top: 6, size: 6, delay: 0.45, color: '#f5d58d', rotation: 72 },
|
||||
{ left: 31, top: -12, size: 8, delay: 0.9, color: '#b7a5e5', rotation: 38 },
|
||||
{ left: 46, top: 3, size: 5, delay: 0.2, color: '#f6eee2', rotation: 92 },
|
||||
{ left: 61, top: -9, size: 10, delay: 0.7, color: '#df8e9b', rotation: 140 },
|
||||
{ left: 75, top: 4, size: 6, delay: 0.1, color: '#f5d58d', rotation: 210 },
|
||||
{ left: 89, top: -14, size: 8, delay: 0.58, color: '#9fb7cc', rotation: 265 },
|
||||
{ left: 13, top: 18, size: 5, delay: 1.1, color: '#f6eee2', rotation: 305 },
|
||||
{ left: 39, top: 13, size: 7, delay: 0.32, color: '#df8e9b', rotation: 180 },
|
||||
{ left: 68, top: 17, size: 5, delay: 0.8, color: '#b7a5e5', rotation: 18 },
|
||||
{ left: 83, top: 20, size: 9, delay: 1.28, color: '#ed9a79', rotation: 122 },
|
||||
{ left: 54, top: -18, size: 4, delay: 1.45, color: '#f6eee2', rotation: 245 },
|
||||
];
|
||||
|
||||
export function Confetti() {
|
||||
return (
|
||||
<div className="confetti" aria-hidden="true">
|
||||
{pieces.map((piece, index) => {
|
||||
const style = {
|
||||
left: `${piece.left}%`,
|
||||
top: `${piece.top}%`,
|
||||
width: `${piece.size}px`,
|
||||
height: `${piece.size * 2.1}px`,
|
||||
backgroundColor: piece.color,
|
||||
animationDelay: `${piece.delay}s`,
|
||||
transform: `rotate(${piece.rotation}deg)`,
|
||||
} satisfies CSSProperties;
|
||||
|
||||
return <span className="confetti__piece" key={index} style={style} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import type { Gift } from '../../types/gift';
|
||||
import { Background } from './Background';
|
||||
import { Confetti } from './Confetti';
|
||||
import { GiftItem } from './GiftItem';
|
||||
import { IntroScreen } from './IntroScreen';
|
||||
import { ProgressIndicator } from './ProgressIndicator';
|
||||
import { RevealScreen } from './RevealScreen';
|
||||
|
||||
type ExperienceStage = 'INTRO' | 'OPEN_PROMPT' | 'REVEAL' | 'CONTENT' | 'FINISH';
|
||||
|
||||
interface GiftExperienceProps {
|
||||
gift: Gift;
|
||||
}
|
||||
|
||||
const screenTransition = {
|
||||
duration: 0.65,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
};
|
||||
|
||||
export function GiftExperience({ gift }: GiftExperienceProps) {
|
||||
const [stage, setStage] = useState<ExperienceStage>('INTRO');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'REVEAL') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setStage(gift.items.length > 0 ? 'CONTENT' : 'FINISH');
|
||||
}, 1550);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [gift.items.length, stage]);
|
||||
|
||||
function showNextItem() {
|
||||
if (activeIndex < gift.items.length - 1) {
|
||||
setActiveIndex((index) => index + 1);
|
||||
return;
|
||||
}
|
||||
setStage('FINISH');
|
||||
}
|
||||
|
||||
function replay() {
|
||||
setActiveIndex(0);
|
||||
setStage('INTRO');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gift-experience" aria-live="polite">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{stage === 'INTRO' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="intro"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<IntroScreen gift={gift} onContinue={() => setStage('OPEN_PROMPT')} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === 'OPEN_PROMPT' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="open-prompt"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<RevealScreen
|
||||
recipientName={gift.recipientName}
|
||||
isRevealing={false}
|
||||
onReveal={() => setStage('REVEAL')}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === 'REVEAL' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="reveal"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<RevealScreen
|
||||
recipientName={gift.recipientName}
|
||||
isRevealing
|
||||
onReveal={() => undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === 'CONTENT' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="content"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<Background tone="content">
|
||||
<div className="content-screen">
|
||||
<div className="content-screen__topline">
|
||||
<span className="brand-lockup brand-lockup--ink">
|
||||
<span>little</span>
|
||||
<span>something</span>
|
||||
</span>
|
||||
<span className="topline-note">a few things for you</span>
|
||||
</div>
|
||||
|
||||
<div className="content-screen__intro">
|
||||
<p className="eyebrow eyebrow--ink">chapter {activeIndex + 1}</p>
|
||||
<h1 className="display">
|
||||
For the moments
|
||||
<br />
|
||||
<em>worth keeping.</em>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ProgressIndicator current={activeIndex} total={gift.items.length} />
|
||||
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{gift.items[activeIndex] && (
|
||||
<motion.div
|
||||
className="content-screen__item"
|
||||
key={gift.items[activeIndex].id}
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -24 }}
|
||||
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<GiftItem item={gift.items[activeIndex]} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="content-screen__bottomline">
|
||||
<span>for {gift.recipientName}</span>
|
||||
<button className="next-action" type="button" onClick={showNextItem}>
|
||||
<span>
|
||||
{activeIndex === gift.items.length - 1 ? 'Keep this moment' : 'Next memory'}
|
||||
</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Background>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === 'FINISH' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="finish"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<Background tone="finish">
|
||||
<Confetti />
|
||||
<div className="finish-screen">
|
||||
<div className="finish-screen__topline">
|
||||
<span className="brand-lockup brand-lockup--light">
|
||||
<span>little</span>
|
||||
<span>something</span>
|
||||
</span>
|
||||
<span className="topline-note">the end, for now</span>
|
||||
</div>
|
||||
<div className="finish-screen__center">
|
||||
<motion.div
|
||||
className="finish-screen__spark"
|
||||
initial={{ scale: 0, rotate: -20 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.25, type: 'spring', stiffness: 180, damping: 12 }}
|
||||
>
|
||||
<span aria-hidden="true">✦</span>
|
||||
</motion.div>
|
||||
<p className="eyebrow eyebrow--quiet">a final note</p>
|
||||
<h1 className="display">
|
||||
Made with love,
|
||||
<br />
|
||||
<em>{gift.recipientName}.</em>
|
||||
</h1>
|
||||
<p className="finish-screen__message">{gift.revealMessage}</p>
|
||||
<p className="finish-screen__signature">
|
||||
Always,
|
||||
<br />
|
||||
<strong>{gift.senderName}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-action ghost-action--light finish-screen__replay"
|
||||
type="button"
|
||||
onClick={replay}
|
||||
>
|
||||
<span>See it again</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</Background>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { GiftItem as GiftItemData } from '../../types/gift';
|
||||
import { ImageItem } from './ImageItem';
|
||||
import { TextItem } from './TextItem';
|
||||
import { VideoItem } from './VideoItem';
|
||||
|
||||
interface GiftItemProps {
|
||||
item: GiftItemData;
|
||||
}
|
||||
|
||||
export function GiftItem({ item }: GiftItemProps) {
|
||||
switch (item.type.toLowerCase()) {
|
||||
case 'image':
|
||||
case 'photo':
|
||||
return <ImageItem item={item} />;
|
||||
case 'video':
|
||||
return <VideoItem item={item} />;
|
||||
case 'text':
|
||||
case 'note':
|
||||
case 'card':
|
||||
case 'link':
|
||||
default:
|
||||
return <TextItem item={item} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { GiftItem as GiftItemData } from '../../types/gift';
|
||||
|
||||
interface ImageItemProps {
|
||||
item: GiftItemData;
|
||||
}
|
||||
|
||||
export function ImageItem({ item }: ImageItemProps) {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="gift-image-item">
|
||||
<div className="gift-image-item__frame">
|
||||
{item.mediaUrl && !hasError ? (
|
||||
<img
|
||||
src={item.mediaUrl}
|
||||
alt={item.title || 'A memory from your gift'}
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="gift-image-item__placeholder">
|
||||
<span className="placeholder-sun" aria-hidden="true" />
|
||||
<span>{hasError ? 'A memory for you' : 'Your image goes here'}</span>
|
||||
</div>
|
||||
)}
|
||||
{(item.title || item.text) && (
|
||||
<div className="gift-image-item__caption">
|
||||
{item.title && <strong>{item.title}</strong>}
|
||||
{item.text && <span>{item.text}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import type { Gift } from '../../types/gift';
|
||||
import { Background } from './Background';
|
||||
|
||||
interface IntroScreenProps {
|
||||
gift: Gift;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function IntroScreen({ gift, onContinue }: IntroScreenProps) {
|
||||
return (
|
||||
<Background tone="intro">
|
||||
<div className="intro-screen">
|
||||
<motion.div
|
||||
className="intro-screen__topline"
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.1 }}
|
||||
>
|
||||
<span className="brand-lockup">
|
||||
<span>little</span>
|
||||
<span>something</span>
|
||||
</span>
|
||||
<span className="topline-note">A private little moment</span>
|
||||
</motion.div>
|
||||
|
||||
<div className="intro-screen__center">
|
||||
<motion.p
|
||||
className="eyebrow eyebrow--warm"
|
||||
initial={{ opacity: 0, letterSpacing: '0.28em' }}
|
||||
animate={{ opacity: 1, letterSpacing: '0.18em' }}
|
||||
transition={{ duration: 0.9, delay: 0.3 }}
|
||||
>
|
||||
{gift.title}
|
||||
</motion.p>
|
||||
<motion.h1
|
||||
className="display intro-screen__title"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.9, delay: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
Hey <em>{gift.recipientName}.</em>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
className="intro-screen__message"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.65 }}
|
||||
>
|
||||
{gift.introMessage}
|
||||
</motion.p>
|
||||
<motion.button
|
||||
className="primary-action"
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.85 }}
|
||||
whileHover={{ y: -3 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span>Open your surprise</span>
|
||||
<span className="primary-action__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M4 10h11M10.5 4.5 16 10l-5.5 5.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="intro-screen__footer"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 1.1 }}
|
||||
>
|
||||
<span>made with intention</span>
|
||||
<span>
|
||||
from <strong>{gift.senderName}</strong>
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
interface ProgressIndicatorProps {
|
||||
current: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function ProgressIndicator({ current, total }: ProgressIndicatorProps) {
|
||||
return (
|
||||
<div className="progress-indicator" aria-label={`Memory ${current + 1} of ${total}`}>
|
||||
<div className="progress-indicator__track" aria-hidden="true">
|
||||
{Array.from({ length: total }, (_, index) => (
|
||||
<span
|
||||
className={`progress-indicator__segment ${index <= current ? 'is-active' : ''}`}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="progress-indicator__count">
|
||||
{String(current + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { Background } from './Background';
|
||||
|
||||
interface RevealScreenProps {
|
||||
recipientName: string;
|
||||
isRevealing: boolean;
|
||||
onReveal: () => void;
|
||||
}
|
||||
|
||||
export function RevealScreen({ recipientName, isRevealing, onReveal }: RevealScreenProps) {
|
||||
return (
|
||||
<Background tone="reveal">
|
||||
<div className="reveal-screen">
|
||||
<div className="reveal-screen__topline">
|
||||
<span className="brand-lockup brand-lockup--light">
|
||||
<span>little</span>
|
||||
<span>something</span>
|
||||
</span>
|
||||
<span className="topline-note">just for {recipientName}</span>
|
||||
</div>
|
||||
|
||||
<div className="reveal-screen__center">
|
||||
<motion.div
|
||||
className="reveal-orbit reveal-orbit--outer"
|
||||
animate={isRevealing ? { rotate: 360, scale: 1.2 } : { rotate: 0, scale: 1 }}
|
||||
transition={{ duration: 1.5, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="reveal-orbit reveal-orbit--inner"
|
||||
animate={isRevealing ? { rotate: -360, scale: 0.76 } : { rotate: 0, scale: 1 }}
|
||||
transition={{ duration: 1.35, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="reveal-seal"
|
||||
animate={
|
||||
isRevealing
|
||||
? { scale: [1, 1.06, 0.2], opacity: [1, 1, 0], rotate: [0, -8, 18] }
|
||||
: { scale: 1, opacity: 1, rotate: 0 }
|
||||
}
|
||||
transition={{ duration: 1.25, times: [0, 0.48, 1], ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<span className="reveal-seal__halo" />
|
||||
<span className="reveal-seal__mark">ls</span>
|
||||
<span className="reveal-seal__label">open gently</span>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
className="reveal-screen__copy"
|
||||
key={isRevealing ? 'revealing' : 'prompt'}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
>
|
||||
<p className="eyebrow eyebrow--quiet">
|
||||
{isRevealing ? 'here it comes' : 'one tiny step'}
|
||||
</p>
|
||||
<h1 className="display">
|
||||
{isRevealing ? 'Making room for a little magic.' : 'There is something inside.'}
|
||||
</h1>
|
||||
<p>
|
||||
{isRevealing
|
||||
? 'Take a breath. Your moment is opening.'
|
||||
: 'No rush. This one was made to be opened slowly.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{!isRevealing && (
|
||||
<motion.button
|
||||
className="ghost-action ghost-action--light"
|
||||
type="button"
|
||||
onClick={onReveal}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span>Unwrap the moment</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { GiftItem as GiftItemData } from '../../types/gift';
|
||||
|
||||
interface TextItemProps {
|
||||
item: GiftItemData;
|
||||
}
|
||||
|
||||
export function TextItem({ item }: TextItemProps) {
|
||||
const eyebrow =
|
||||
typeof item.metadata?.eyebrow === 'string' ? item.metadata.eyebrow : 'A note from me';
|
||||
|
||||
return (
|
||||
<div className="gift-text-item">
|
||||
<div className="gift-text-item__quote" aria-hidden="true">
|
||||
“
|
||||
</div>
|
||||
<p className="eyebrow eyebrow--ink">{eyebrow}</p>
|
||||
{item.title && <h2 className="display">{item.title}</h2>}
|
||||
<p className="gift-text-item__body">{item.text || 'A little note, just because.'}</p>
|
||||
<div className="gift-text-item__signature" aria-hidden="true">
|
||||
<span />
|
||||
<span>with love</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { GiftItem as GiftItemData } from '../../types/gift';
|
||||
|
||||
interface VideoItemProps {
|
||||
item: GiftItemData;
|
||||
}
|
||||
|
||||
export function VideoItem({ item }: VideoItemProps) {
|
||||
return (
|
||||
<div className="gift-video-item">
|
||||
{item.mediaUrl ? (
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={item.metadata?.poster as string | undefined}
|
||||
>
|
||||
<source src={item.mediaUrl} />
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
) : (
|
||||
<div className="gift-video-item__placeholder">
|
||||
<span className="video-play" aria-hidden="true">
|
||||
▶
|
||||
</span>
|
||||
<span>Your video goes here</span>
|
||||
</div>
|
||||
)}
|
||||
{item.text && <p>{item.text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import { getMe, logout, type User } from '../../lib/api';
|
||||
|
||||
import { AuthContext } from './AuthContextValue';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then(setUser)
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function signOut() {
|
||||
await logout().catch(() => undefined);
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, setUser, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* The hook lives in useAuth.ts so Fast Refresh only sees the provider here. */
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { User } from '../../lib/api';
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { AuthContext } from './AuthContextValue';
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used inside AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { Gift } from '../types/gift';
|
||||
import type { DevDiagnostics, DevStorageCheck } from '../types/dev';
|
||||
import type {
|
||||
DownloadJob,
|
||||
GalleryDetail,
|
||||
GallerySummary,
|
||||
MediaItem,
|
||||
PublicGallery,
|
||||
} from '../types/gallery';
|
||||
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (options.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
throw new ApiError('The server could not be reached. Check your connection and try again.', 0);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
||||
const message = typeof payload?.error === 'string' ? payload.error : 'Something went wrong.';
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function getGift(slug: string, signal?: AbortSignal): Promise<Gift> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${apiBaseUrl}/api/gifts/${encodeURIComponent(slug)}`, { signal });
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
throw new ApiError(
|
||||
'The surprise could not be reached. Check your connection and try again.',
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
||||
const message =
|
||||
typeof payload?.error === 'string' ? payload.error : 'This gift is not available.';
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return (await response.json()) as Gift;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<User> {
|
||||
const response = await request<{ user: User }>('/api/auth/me');
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<User> {
|
||||
const response = await request<{ user: User }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function register(name: string, email: string, password: string): Promise<User> {
|
||||
const response = await request<{ user: User }>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await request('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function getGalleries(): Promise<GallerySummary[]> {
|
||||
const response = await request<{ galleries: GallerySummary[] }>('/api/galleries');
|
||||
return response.galleries;
|
||||
}
|
||||
|
||||
export async function getGallery(id: string): Promise<GalleryDetail> {
|
||||
const response = await request<{ gallery: GalleryDetail }>(`/api/galleries/${id}`);
|
||||
return response.gallery;
|
||||
}
|
||||
|
||||
export async function getGalleryPreview(id: string): Promise<PublicGallery> {
|
||||
const response = await request<{ gallery: PublicGallery }>(`/api/galleries/${id}/preview`);
|
||||
return response.gallery;
|
||||
}
|
||||
|
||||
export async function createGallery(input: {
|
||||
title: string;
|
||||
clientName: string;
|
||||
description: string;
|
||||
}): Promise<GalleryDetail> {
|
||||
const response = await request<{ gallery: GalleryDetail }>('/api/galleries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return response.gallery;
|
||||
}
|
||||
|
||||
export interface UpdateGalleryInput {
|
||||
title?: string;
|
||||
clientName?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
clearPassword?: boolean;
|
||||
downloadsEnabled?: boolean;
|
||||
favoritesEnabled?: boolean;
|
||||
downloadAllEnabled?: boolean;
|
||||
watermarkEnabled?: boolean;
|
||||
expiresAt?: string;
|
||||
coverMediaId?: string;
|
||||
themeConfig?: Record<string, unknown>;
|
||||
brandingConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function updateGallery(id: string, input: UpdateGalleryInput): Promise<GalleryDetail> {
|
||||
const response = await request<{ gallery: GalleryDetail }>(`/api/galleries/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return response.gallery;
|
||||
}
|
||||
|
||||
export async function publishGallery(id: string): Promise<GalleryDetail> {
|
||||
const response = await request<{ gallery: GalleryDetail }>(`/api/galleries/${id}/publish`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.gallery;
|
||||
}
|
||||
|
||||
export async function unpublishGallery(id: string): Promise<GalleryDetail> {
|
||||
const response = await request<{ gallery: GalleryDetail }>(`/api/galleries/${id}/unpublish`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.gallery;
|
||||
}
|
||||
|
||||
export async function deleteGallery(id: string): Promise<void> {
|
||||
await request(`/api/galleries/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function createUpload(
|
||||
galleryId: string,
|
||||
file: File,
|
||||
): Promise<{ uploadId: string; uploadUrl: string; media: MediaItem }> {
|
||||
return request(`/api/galleries/${galleryId}/uploads`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename: file.name, mimeType: file.type, fileSize: file.size }),
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadToStorage(
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress: (progress: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url);
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
const detail = xhr.responseText ? ` ${xhr.responseText.slice(0, 160)}` : '';
|
||||
reject(
|
||||
new ApiError(
|
||||
`Direct storage upload failed with HTTP ${xhr.status}.${detail}`,
|
||||
xhr.status,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
let origin = 'the configured object storage';
|
||||
try {
|
||||
origin = new URL(url).origin;
|
||||
} catch {
|
||||
// Keep the actionable generic message when a presigned URL is malformed.
|
||||
}
|
||||
reject(
|
||||
new ApiError(
|
||||
`Could not reach ${origin}. This is usually a MinIO CORS or origin mismatch.`,
|
||||
0,
|
||||
),
|
||||
);
|
||||
};
|
||||
xhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'));
|
||||
signal?.addEventListener('abort', () => xhr.abort(), { once: true });
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeUpload(uploadId: string): Promise<MediaItem> {
|
||||
const response = await request<{ media: MediaItem }>(`/api/uploads/${uploadId}/complete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.media;
|
||||
}
|
||||
|
||||
export async function deleteMedia(mediaId: string): Promise<void> {
|
||||
await request(`/api/media/${mediaId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function updateMediaOrder(mediaId: string, sortOrder: number): Promise<MediaItem> {
|
||||
const response = await request<{ media: MediaItem }>(`/api/media/${mediaId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sortOrder }),
|
||||
});
|
||||
return response.media;
|
||||
}
|
||||
|
||||
export async function getPublicGallery(slug: string): Promise<PublicGallery> {
|
||||
return request<PublicGallery>(`/api/public/galleries/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
|
||||
export async function authenticatePublicGallery(
|
||||
slug: string,
|
||||
password: string,
|
||||
): Promise<PublicGallery> {
|
||||
return request<PublicGallery>(`/api/public/galleries/${encodeURIComponent(slug)}/authenticate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function favoriteMedia(
|
||||
slug: string,
|
||||
mediaId: string,
|
||||
favorited: boolean,
|
||||
): Promise<boolean> {
|
||||
const method = favorited ? 'POST' : 'DELETE';
|
||||
const response = await request<{ favorited: boolean }>(
|
||||
`/api/public/galleries/${encodeURIComponent(slug)}/media/${mediaId}/favorite`,
|
||||
{ method },
|
||||
);
|
||||
return response.favorited;
|
||||
}
|
||||
|
||||
export async function downloadMedia(slug: string, mediaId: string): Promise<string> {
|
||||
const response = await request<{ url: string }>(
|
||||
`/api/public/galleries/${encodeURIComponent(slug)}/media/${mediaId}/download`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return response.url;
|
||||
}
|
||||
|
||||
export async function startDownloadAll(slug: string): Promise<DownloadJob> {
|
||||
return request<DownloadJob>(`/api/public/galleries/${encodeURIComponent(slug)}/download-all`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDownloadAllStatus(slug: string, jobId: string): Promise<DownloadJob> {
|
||||
return request<DownloadJob>(
|
||||
`/api/public/galleries/${encodeURIComponent(slug)}/download-all/${jobId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getDevDiagnostics(): Promise<DevDiagnostics> {
|
||||
return request<DevDiagnostics>('/api/dev/diagnostics');
|
||||
}
|
||||
|
||||
export async function runDevStorageCheck(): Promise<DevStorageCheck> {
|
||||
return request<DevStorageCheck>('/api/dev/storage-check', { method: 'POST' });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function formatBytes(bytes: number) {
|
||||
if (bytes === 0) return '0 MB';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function formatDate(value: string) {
|
||||
if (!value) return 'Not dated';
|
||||
const date = new Date(value.replace(' ', 'T'));
|
||||
if (Number.isNaN(date.getTime())) return 'Not dated';
|
||||
return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' }).format(
|
||||
date,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDuration(seconds?: number) {
|
||||
if (!seconds || seconds < 1) return '';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
return `${minutes}:${remainder}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
import './styles/index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { ClientGallery } from '../components/gallery/ClientGallery';
|
||||
import { getGalleryPreview } from '../lib/api';
|
||||
import type { PublicGallery } from '../types/gallery';
|
||||
import { ClientError, ClientLoading } from './PublicGalleryPage';
|
||||
|
||||
export default function GalleryPreviewPage() {
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
const [gallery, setGallery] = useState<PublicGallery | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getGalleryPreview(id)
|
||||
.then(setGallery)
|
||||
.catch((reason: unknown) =>
|
||||
setError(reason instanceof Error ? reason.message : 'Could not load preview.'),
|
||||
);
|
||||
}, [id]);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<>
|
||||
<ClientError message={error} />
|
||||
<Link className="preview-return-link" to={`/dashboard/galleries/${id}/edit`}>
|
||||
Return to editor
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
if (!gallery) return <ClientLoading />;
|
||||
return <ClientGallery gallery={gallery} />;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Background } from '../components/gift/Background';
|
||||
import { GiftExperience } from '../components/gift/GiftExperience';
|
||||
import { getGift } from '../lib/api';
|
||||
import type { Gift } from '../types/gift';
|
||||
|
||||
type GiftRequestState =
|
||||
{ status: 'loading' } | { status: 'success'; gift: Gift } | { status: 'error'; message: string };
|
||||
|
||||
export default function GiftPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [request, setRequest] = useState<GiftRequestState>({ status: 'loading' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setRequest({ status: 'loading' });
|
||||
|
||||
getGift(slug, controller.signal)
|
||||
.then((gift) => setRequest({ status: 'success', gift }))
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
setRequest({
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'This gift could not be opened.',
|
||||
});
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [attempt, slug]);
|
||||
|
||||
if (request.status === 'success') {
|
||||
return <GiftExperience gift={request.gift} />;
|
||||
}
|
||||
|
||||
if (request.status === 'error') {
|
||||
return (
|
||||
<Background tone="intro">
|
||||
<div className="request-state">
|
||||
<p className="eyebrow eyebrow--warm">a small hiccup</p>
|
||||
<h1 className="display">This moment is hiding.</h1>
|
||||
<p>{request.message}</p>
|
||||
<button
|
||||
className="primary-action"
|
||||
type="button"
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
<span>Try again</span>
|
||||
<span className="primary-action__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M4 10h11M10.5 4.5 16 10l-5.5 5.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Background tone="intro">
|
||||
<div
|
||||
className="request-state request-state--loading"
|
||||
aria-busy="true"
|
||||
aria-label="Loading your gift"
|
||||
>
|
||||
<span className="loading-mark" aria-hidden="true">
|
||||
ls
|
||||
</span>
|
||||
<p className="eyebrow eyebrow--warm">opening something special</p>
|
||||
<h1 className="display">Just a moment.</h1>
|
||||
<span className="loading-line" aria-hidden="true" />
|
||||
</div>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AuthLayout } from '../components/app/AuthLayout';
|
||||
import { useAuth } from '../features/auth/useAuth';
|
||||
import { login } from '../lib/api';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setUser } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const user = await login(email, password);
|
||||
setUser(user);
|
||||
const destination = (location.state as { from?: string } | null)?.from || '/dashboard';
|
||||
navigate(destination, { replace: true });
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not sign in.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className="auth-card">
|
||||
<div className="auth-card__heading">
|
||||
<p className="platform-kicker platform-kicker--accent">Welcome back</p>
|
||||
<h2 className="platform-display">
|
||||
Your work,
|
||||
<br />
|
||||
<em>waiting.</em>
|
||||
</h2>
|
||||
<p>Sign in to keep shaping the way your clients experience their photographs.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
Email address
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button
|
||||
className="platform-button platform-button--dark"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
<span>{submitting ? 'Opening studio...' : 'Sign in'}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-card__footer">
|
||||
New to Northline? <Link to="/register">Create an account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="client-error client-error--not-found">
|
||||
<span className="client-loading__mark">N</span>
|
||||
<p className="client-kicker">Nothing here yet</p>
|
||||
<h1 className="client-display">
|
||||
This link took
|
||||
<br />
|
||||
<em>a wrong turn.</em>
|
||||
</h1>
|
||||
<p>The gallery you are looking for may have moved or never existed.</p>
|
||||
<Link className="client-download-ready" to="/login">
|
||||
Return to studio <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ClientGallery } from '../components/gallery/ClientGallery';
|
||||
import { PasswordGate } from '../components/gallery/PasswordGate';
|
||||
import { getPublicGallery, authenticatePublicGallery } from '../lib/api';
|
||||
import type { PublicGallery } from '../types/gallery';
|
||||
|
||||
export default function PublicGalleryPage() {
|
||||
const { slug = '' } = useParams<{ slug: string }>();
|
||||
const [gallery, setGallery] = useState<PublicGallery | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getPublicGallery(slug)
|
||||
.then(setGallery)
|
||||
.catch((reason: unknown) =>
|
||||
setError(reason instanceof Error ? reason.message : 'This gallery could not be opened.'),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return <ClientLoading />;
|
||||
}
|
||||
if (error || !gallery) {
|
||||
return <ClientError message={error || 'This gallery could not be opened.'} />;
|
||||
}
|
||||
if (gallery.requiresPassword) {
|
||||
return (
|
||||
<PasswordGate
|
||||
gallery={gallery}
|
||||
onUnlock={async (password) => setGallery(await authenticatePublicGallery(slug, password))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ClientGallery gallery={gallery} />;
|
||||
}
|
||||
|
||||
export function ClientLoading() {
|
||||
return (
|
||||
<div className="client-loading">
|
||||
<span className="client-loading__mark">N</span>
|
||||
<p>Preparing your gallery</p>
|
||||
<span className="client-loading__line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="client-error">
|
||||
<span className="client-loading__mark">N</span>
|
||||
<p className="client-kicker">A quiet moment</p>
|
||||
<h1 className="client-display">
|
||||
This gallery is
|
||||
<br />
|
||||
<em>out of reach.</em>
|
||||
</h1>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AuthLayout } from '../components/app/AuthLayout';
|
||||
import { useAuth } from '../features/auth/useAuth';
|
||||
import { register } from '../lib/api';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setUser } = useAuth();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const user = await register(name, email, password);
|
||||
setUser(user);
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not create your account.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className="auth-card">
|
||||
<div className="auth-card__heading">
|
||||
<p className="platform-kicker platform-kicker--accent">Start your studio</p>
|
||||
<h2 className="platform-display">
|
||||
Make the handoff
|
||||
<br />
|
||||
<em>matter.</em>
|
||||
</h2>
|
||||
<p>Create a private home for the work your clients have been waiting to see.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
Studio or photographer name
|
||||
<input
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Email address
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button
|
||||
className="platform-button platform-button--dark"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
<span>{submitting ? 'Creating studio...' : 'Create account'}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-card__footer">
|
||||
Already have an account? <Link to="/login">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
|
||||
import { GalleryCard } from '../../components/dashboard/GalleryCard';
|
||||
import { StudioStat } from '../../components/dashboard/StudioStat';
|
||||
import { useAuth } from '../../features/auth/useAuth';
|
||||
import { deleteGallery, getGalleries } from '../../lib/api';
|
||||
import { formatBytes } from '../../lib/format';
|
||||
import type { GallerySummary } from '../../types/gallery';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const [galleries, setGalleries] = useState<GallerySummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getGalleries()
|
||||
.then(setGalleries)
|
||||
.catch((reason: unknown) =>
|
||||
setError(reason instanceof Error ? reason.message : 'Could not load galleries.'),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function removeGallery(gallery: GallerySummary) {
|
||||
if (!window.confirm(`Delete "${gallery.title}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteGallery(gallery.id);
|
||||
setGalleries((current) => current.filter((item) => item.id !== gallery.id));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not delete gallery.');
|
||||
}
|
||||
}
|
||||
|
||||
const photos = galleries.reduce((total, gallery) => total + gallery.photoCount, 0);
|
||||
const storage = galleries.reduce((total, gallery) => total + gallery.totalBytes, 0);
|
||||
const published = galleries.filter((gallery) => gallery.status === 'published').length;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="studio-page">
|
||||
<header className="studio-page__header">
|
||||
<div>
|
||||
<p className="studio-kicker">{user?.name || 'Studio'} / overview</p>
|
||||
<h1 className="studio-display">
|
||||
Make the handoff <em>matter.</em>
|
||||
</h1>
|
||||
<p className="studio-page__lede">
|
||||
Everything your clients need, in one beautiful place.
|
||||
</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||
<span>New gallery</span>
|
||||
<span aria-hidden="true">+</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="studio-stats" aria-label="Studio overview">
|
||||
<StudioStat
|
||||
label="Galleries"
|
||||
value={String(galleries.length).padStart(2, '0')}
|
||||
note="All your work, in one place"
|
||||
accent="coral"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Published"
|
||||
value={String(published).padStart(2, '0')}
|
||||
note="Currently out in the world"
|
||||
accent="violet"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Photographs"
|
||||
value={String(photos).padStart(2, '0')}
|
||||
note="Ready to be remembered"
|
||||
accent="gold"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Storage used"
|
||||
value={formatBytes(storage)}
|
||||
note="Across every gallery"
|
||||
accent="ink"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="studio-section studio-section--recent">
|
||||
<div className="studio-section__heading">
|
||||
<div>
|
||||
<p className="studio-kicker">Your latest work</p>
|
||||
<h2 className="studio-heading">Recent galleries</h2>
|
||||
</div>
|
||||
<Link className="inline-link" to="/dashboard/galleries">
|
||||
View all galleries <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||
{loading ? (
|
||||
<div className="studio-list-loading">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
) : galleries.length === 0 ? (
|
||||
<div className="studio-empty">
|
||||
<span className="studio-empty__mark">+</span>
|
||||
<div>
|
||||
<h3>Your first gallery starts here.</h3>
|
||||
<p>Give finished work a place that feels as considered as the work itself.</p>
|
||||
</div>
|
||||
<Link className="inline-link" to="/dashboard/galleries/new">
|
||||
Create a gallery <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gallery-grid gallery-grid--dashboard">
|
||||
{galleries.slice(0, 3).map((gallery) => (
|
||||
<GalleryCard key={gallery.id} gallery={gallery} onDelete={removeGallery} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
|
||||
import { getDevDiagnostics, runDevStorageCheck } from '../../lib/api';
|
||||
import type { DevDiagnostics, DevStorageCheck } from '../../types/dev';
|
||||
|
||||
interface UploadErrorLog {
|
||||
at: string;
|
||||
filename: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function DevPage() {
|
||||
const [diagnostics, setDiagnostics] = useState<DevDiagnostics | null>(null);
|
||||
const [storageCheck, setStorageCheck] = useState<DevStorageCheck | null>(null);
|
||||
const [uploadError, setUploadError] = useState<UploadErrorLog | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
setDiagnostics(await getDevDiagnostics());
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not load diagnostics.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
try {
|
||||
const raw = window.localStorage.getItem('northline:last-upload-error');
|
||||
if (raw) setUploadError(JSON.parse(raw) as UploadErrorLog);
|
||||
} catch {
|
||||
setUploadError(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function checkStorage() {
|
||||
setChecking(true);
|
||||
try {
|
||||
setStorageCheck(await runDevStorageCheck());
|
||||
} catch (reason) {
|
||||
setStorageCheck({
|
||||
ok: false,
|
||||
step: 'api',
|
||||
error: reason instanceof Error ? reason.message : 'Storage check failed.',
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
void refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const browser = {
|
||||
origin: window.location.origin,
|
||||
online: navigator.onLine,
|
||||
xhr: typeof XMLHttpRequest !== 'undefined',
|
||||
fileApi: typeof File !== 'undefined' && typeof FileReader !== 'undefined',
|
||||
secureContext: window.isSecureContext,
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="studio-page dev-page">
|
||||
<header className="studio-page__header studio-page__header--compact">
|
||||
<div>
|
||||
<p className="studio-kicker">Workspace / diagnostics</p>
|
||||
<h1 className="studio-display">
|
||||
The <em>workbench.</em>
|
||||
</h1>
|
||||
<p className="studio-page__lede">
|
||||
Useful, non-secret signals for local development and upload debugging.
|
||||
</p>
|
||||
</div>
|
||||
<div className="dev-page__actions">
|
||||
<button
|
||||
className="editor-button editor-button--quiet"
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="editor-button editor-button--accent"
|
||||
type="button"
|
||||
onClick={() => void checkStorage()}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? 'Checking...' : 'Test storage'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||
<div className="dev-grid">
|
||||
<DevPanel title="Runtime" eyebrow="01 / API">
|
||||
{loading || !diagnostics ? (
|
||||
<DevLoading />
|
||||
) : (
|
||||
<>
|
||||
<DevRow label="Environment" value={diagnostics.environment} />
|
||||
<DevRow label="API time" value={diagnostics.now} />
|
||||
<DevRow label="Account" value={diagnostics.user.email} />
|
||||
<DevRow
|
||||
label="Session cookie"
|
||||
value={diagnostics.http.cookieSecure ? 'Secure' : 'Local / insecure'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DevPanel>
|
||||
<DevPanel title="Database" eyebrow="02 / Persistence">
|
||||
{loading || !diagnostics ? (
|
||||
<DevLoading />
|
||||
) : (
|
||||
<>
|
||||
<DevStatus
|
||||
label={diagnostics.database.driver}
|
||||
ok={diagnostics.database.connected}
|
||||
/>
|
||||
<DevRow
|
||||
label="Connection"
|
||||
value={
|
||||
diagnostics.database.connected
|
||||
? 'Reachable'
|
||||
: diagnostics.database.error || 'Unavailable'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DevPanel>
|
||||
<DevPanel title="Object storage" eyebrow="03 / MinIO">
|
||||
{loading || !diagnostics ? (
|
||||
<DevLoading />
|
||||
) : (
|
||||
<>
|
||||
<DevStatus
|
||||
label={diagnostics.storage.provider}
|
||||
ok={diagnostics.storage.reachable}
|
||||
/>
|
||||
<DevRow label="Endpoint" value={diagnostics.storage.endpoint} />
|
||||
<DevRow label="Bucket" value={diagnostics.storage.bucket} />
|
||||
<DevRow label="Protocol" value={diagnostics.storage.secure ? 'HTTPS' : 'HTTP'} />
|
||||
{diagnostics.storage.error && (
|
||||
<p className="dev-panel__error">{diagnostics.storage.error}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DevPanel>
|
||||
<DevPanel title="Browser" eyebrow="04 / Client">
|
||||
<DevRow label="Origin" value={browser.origin} />
|
||||
<DevRow label="Network" value={browser.online ? 'Online' : 'Offline'} />
|
||||
<DevRow label="XMLHttpRequest" value={browser.xhr ? 'Available' : 'Unavailable'} />
|
||||
<DevRow label="File API" value={browser.fileApi ? 'Available' : 'Unavailable'} />
|
||||
<DevRow
|
||||
label="Secure context"
|
||||
value={browser.secureContext ? 'Yes' : 'No (normal for local HTTP)'}
|
||||
/>
|
||||
</DevPanel>
|
||||
</div>
|
||||
<section className="dev-panel dev-panel--wide">
|
||||
<div className="dev-panel__heading">
|
||||
<p className="studio-kicker">05 / Upload path</p>
|
||||
<h2>What happens when you choose a file.</h2>
|
||||
</div>
|
||||
<div className="dev-flow">
|
||||
<span>
|
||||
01 <strong>API URL</strong>
|
||||
<small>Creates media metadata</small>
|
||||
</span>
|
||||
<i>→</i>
|
||||
<span>
|
||||
02 <strong>Presigned PUT</strong>
|
||||
<small>Browser to MinIO</small>
|
||||
</span>
|
||||
<i>→</i>
|
||||
<span>
|
||||
03 <strong>Complete</strong>
|
||||
<small>Stat object and queue processing</small>
|
||||
</span>
|
||||
<i>→</i>
|
||||
<span>
|
||||
04 <strong>Ready</strong>
|
||||
<small>Preview becomes visible</small>
|
||||
</span>
|
||||
</div>
|
||||
<p className="dev-hint">
|
||||
If an upload stops at 0%, the failure is usually the browser reaching the presigned
|
||||
MinIO origin. Check that the browser origin below is listed in the MinIO bucket CORS
|
||||
rule.
|
||||
</p>
|
||||
{diagnostics && (
|
||||
<div className="dev-cors">
|
||||
<span>Effective browser origins</span>
|
||||
{diagnostics.http.corsOrigins.map((origin) => (
|
||||
<code key={origin}>{origin}</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="dev-panel dev-panel--wide">
|
||||
<div className="dev-panel__heading">
|
||||
<p className="studio-kicker">06 / Last client failure</p>
|
||||
<h2>Recent upload signal.</h2>
|
||||
</div>
|
||||
{uploadError ? (
|
||||
<div className="dev-last-error">
|
||||
<span className="dev-last-error__mark">!</span>
|
||||
<div>
|
||||
<strong>{uploadError.filename}</strong>
|
||||
<p>{uploadError.message}</p>
|
||||
<small>{new Date(uploadError.at).toLocaleString()}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem('northline:last-upload-error');
|
||||
setUploadError(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dev-empty">
|
||||
No client-side upload failures have been recorded in this browser.
|
||||
</p>
|
||||
)}
|
||||
{storageCheck && (
|
||||
<div className={`dev-check-result ${storageCheck.ok ? 'is-ok' : 'is-failed'}`}>
|
||||
<strong>{storageCheck.ok ? 'Storage check passed' : 'Storage check failed'}</strong>
|
||||
<span>
|
||||
{storageCheck.ok
|
||||
? `${storageCheck.bytes} bytes / ${storageCheck.contentType}`
|
||||
: `${storageCheck.step}: ${storageCheck.error}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<details className="dev-raw">
|
||||
<summary>Show raw diagnostics JSON</summary>
|
||||
<pre>{diagnostics ? JSON.stringify(diagnostics, null, 2) : 'No response yet.'}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DevPanel({
|
||||
title,
|
||||
eyebrow,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="dev-panel">
|
||||
<div className="dev-panel__heading">
|
||||
<p className="studio-kicker">{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DevRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="dev-row">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DevStatus({ label, ok }: { label: string; ok: boolean }) {
|
||||
return (
|
||||
<div className={`dev-status ${ok ? 'is-ok' : 'is-failed'}`}>
|
||||
<i /> <strong>{label}</strong>
|
||||
<span>{ok ? 'reachable' : 'unavailable'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DevLoading() {
|
||||
return (
|
||||
<div className="dev-loading">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
|
||||
import { GalleryCard } from '../../components/dashboard/GalleryCard';
|
||||
import { deleteGallery, getGalleries } from '../../lib/api';
|
||||
import type { GallerySummary } from '../../types/gallery';
|
||||
|
||||
export default function GalleriesPage() {
|
||||
const [galleries, setGalleries] = useState<GallerySummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getGalleries()
|
||||
.then(setGalleries)
|
||||
.catch((reason: unknown) =>
|
||||
setError(reason instanceof Error ? reason.message : 'Could not load galleries.'),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function removeGallery(gallery: GallerySummary) {
|
||||
if (!window.confirm(`Delete "${gallery.title}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteGallery(gallery.id);
|
||||
setGalleries((current) => current.filter((item) => item.id !== gallery.id));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not delete gallery.');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="studio-page">
|
||||
<header className="studio-page__header studio-page__header--compact">
|
||||
<div>
|
||||
<p className="studio-kicker">Workspace / library</p>
|
||||
<h1 className="studio-display">
|
||||
Your <em>galleries.</em>
|
||||
</h1>
|
||||
<p className="studio-page__lede">
|
||||
The places where your finished work becomes a shared memory.
|
||||
</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||
<span>New gallery</span>
|
||||
<span aria-hidden="true">+</span>
|
||||
</Link>
|
||||
</header>
|
||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||
{loading ? (
|
||||
<div className="studio-list-loading">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
) : galleries.length === 0 ? (
|
||||
<div className="studio-empty studio-empty--large">
|
||||
<span className="studio-empty__mark">+</span>
|
||||
<div>
|
||||
<h3>A quiet room, waiting.</h3>
|
||||
<p>
|
||||
Create a gallery and give your next client a delivery experience they will remember.
|
||||
</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--dark" to="/dashboard/galleries/new">
|
||||
<span>Create your first gallery</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gallery-grid">
|
||||
{galleries.map((gallery) => (
|
||||
<GalleryCard key={gallery.id} gallery={gallery} onDelete={removeGallery} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
|
||||
import { UploadDropzone } from '../../components/dashboard/UploadDropzone';
|
||||
import { useAuth } from '../../features/auth/useAuth';
|
||||
import {
|
||||
createGallery,
|
||||
deleteMedia,
|
||||
getGallery,
|
||||
publishGallery,
|
||||
updateGallery,
|
||||
updateMediaOrder,
|
||||
} from '../../lib/api';
|
||||
import { formatBytes, formatDuration } from '../../lib/format';
|
||||
import type { GalleryDetail, GalleryLayout, GalleryMode, MediaItem } from '../../types/gallery';
|
||||
|
||||
interface EditorDraft {
|
||||
title: string;
|
||||
clientName: string;
|
||||
description: string;
|
||||
downloadsEnabled: boolean;
|
||||
favoritesEnabled: boolean;
|
||||
downloadAllEnabled: boolean;
|
||||
watermarkEnabled: boolean;
|
||||
expiresAt: string;
|
||||
coverMediaId: string;
|
||||
themeMode: GalleryMode;
|
||||
layout: GalleryLayout;
|
||||
accent: string;
|
||||
font: 'sans' | 'serif';
|
||||
studioName: string;
|
||||
tagline: string;
|
||||
websiteUrl: string;
|
||||
instagramUrl: string;
|
||||
}
|
||||
|
||||
const initialDraft: EditorDraft = {
|
||||
title: '',
|
||||
clientName: '',
|
||||
description: '',
|
||||
downloadsEnabled: true,
|
||||
favoritesEnabled: true,
|
||||
downloadAllEnabled: true,
|
||||
watermarkEnabled: false,
|
||||
expiresAt: '',
|
||||
coverMediaId: '',
|
||||
themeMode: 'light',
|
||||
layout: 'editorial',
|
||||
accent: '#ad695b',
|
||||
font: 'serif',
|
||||
studioName: '',
|
||||
tagline: '',
|
||||
websiteUrl: '',
|
||||
instagramUrl: '',
|
||||
};
|
||||
|
||||
function draftFromGallery(gallery: GalleryDetail): EditorDraft {
|
||||
return {
|
||||
title: gallery.title,
|
||||
clientName: gallery.clientName,
|
||||
description: gallery.description,
|
||||
downloadsEnabled: gallery.downloadsEnabled,
|
||||
favoritesEnabled: gallery.favoritesEnabled,
|
||||
downloadAllEnabled: gallery.downloadAllEnabled,
|
||||
watermarkEnabled: gallery.watermarkEnabled,
|
||||
expiresAt: gallery.expiresAt ? gallery.expiresAt.slice(0, 10) : '',
|
||||
coverMediaId: gallery.coverMediaId || '',
|
||||
themeMode: gallery.themeConfig.mode || 'light',
|
||||
layout: gallery.themeConfig.layout || 'editorial',
|
||||
accent: gallery.themeConfig.accent || '#ad695b',
|
||||
font: gallery.themeConfig.font || 'serif',
|
||||
studioName: gallery.brandingConfig.studioName || '',
|
||||
tagline: gallery.brandingConfig.tagline || '',
|
||||
websiteUrl: gallery.brandingConfig.websiteUrl || '',
|
||||
instagramUrl: gallery.brandingConfig.instagramUrl || '',
|
||||
};
|
||||
}
|
||||
|
||||
export default function GalleryEditorPage() {
|
||||
const { id = 'new' } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const isNew = id === 'new';
|
||||
const [gallery, setGallery] = useState<GalleryDetail | null>(null);
|
||||
const [draft, setDraft] = useState<EditorDraft>(initialDraft);
|
||||
const [password, setPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
getGallery(id)
|
||||
.then((value) => {
|
||||
setGallery(value);
|
||||
setDraft(draftFromGallery(value));
|
||||
})
|
||||
.catch((reason: unknown) =>
|
||||
setError(reason instanceof Error ? reason.message : 'Could not load gallery.'),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, isNew]);
|
||||
|
||||
function setField<K extends keyof EditorDraft>(field: K, value: EditorDraft[K]) {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
async function refreshGallery() {
|
||||
if (isNew) return;
|
||||
try {
|
||||
const value = await getGallery(id);
|
||||
setGallery(value);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
coverMediaId: value.coverMediaId || current.coverMediaId,
|
||||
}));
|
||||
} catch {
|
||||
// A completed upload can take a moment to appear while processing.
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<GalleryDetail | null> {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
if (isNew) {
|
||||
const created = await createGallery({
|
||||
title: draft.title,
|
||||
clientName: draft.clientName,
|
||||
description: draft.description,
|
||||
});
|
||||
setGallery(created);
|
||||
setDraft(draftFromGallery(created));
|
||||
navigate(`/dashboard/galleries/${created.id}/edit`, { replace: true });
|
||||
setNotice('Gallery created');
|
||||
return created;
|
||||
}
|
||||
const updated = await updateGallery(id, {
|
||||
title: draft.title,
|
||||
clientName: draft.clientName,
|
||||
description: draft.description,
|
||||
downloadsEnabled: draft.downloadsEnabled,
|
||||
favoritesEnabled: draft.favoritesEnabled,
|
||||
downloadAllEnabled: draft.downloadAllEnabled,
|
||||
watermarkEnabled: draft.watermarkEnabled,
|
||||
expiresAt: draft.expiresAt ? new Date(`${draft.expiresAt}T23:59:59Z`).toISOString() : '',
|
||||
coverMediaId: draft.coverMediaId,
|
||||
themeConfig: {
|
||||
mode: draft.themeMode,
|
||||
layout: draft.layout,
|
||||
accent: draft.accent,
|
||||
font: draft.font,
|
||||
},
|
||||
brandingConfig: {
|
||||
studioName: draft.studioName,
|
||||
tagline: draft.tagline,
|
||||
websiteUrl: draft.websiteUrl,
|
||||
instagramUrl: draft.instagramUrl,
|
||||
},
|
||||
...(password ? { password } : {}),
|
||||
...(clearPassword ? { clearPassword: true } : {}),
|
||||
});
|
||||
setGallery(updated);
|
||||
setDraft(draftFromGallery(updated));
|
||||
setPassword('');
|
||||
setClearPassword(false);
|
||||
setNotice('Changes saved');
|
||||
return updated;
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not save gallery.');
|
||||
return null;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
setPublishing(true);
|
||||
const saved = await save();
|
||||
const galleryId = saved?.id || gallery?.id;
|
||||
if (galleryId) {
|
||||
try {
|
||||
const published = await publishGallery(galleryId);
|
||||
setGallery(published);
|
||||
setNotice('Gallery published');
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not publish gallery.');
|
||||
}
|
||||
}
|
||||
setPublishing(false);
|
||||
}
|
||||
|
||||
async function removeMedia(item: MediaItem) {
|
||||
if (!window.confirm(`Remove ${item.originalFilename}?`)) return;
|
||||
try {
|
||||
await deleteMedia(item.id);
|
||||
setGallery((current) =>
|
||||
current
|
||||
? { ...current, media: current.media.filter((media) => media.id !== item.id) }
|
||||
: current,
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not remove media.');
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMedia(item: MediaItem, direction: -1 | 1) {
|
||||
if (!gallery) return;
|
||||
const index = gallery.media.findIndex((media) => media.id === item.id);
|
||||
const other = gallery.media[index + direction];
|
||||
if (!other) return;
|
||||
try {
|
||||
await updateMediaOrder(item.id, other.sortOrder);
|
||||
await updateMediaOrder(other.id, item.sortOrder);
|
||||
await refreshGallery();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Could not reorder media.');
|
||||
}
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="studio-page studio-page--loading">
|
||||
<span className="app-loading__mark">N</span>
|
||||
<p>Opening gallery editor...</p>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="editor-page">
|
||||
<header className="editor-header">
|
||||
<div className="editor-header__back">
|
||||
<Link to="/dashboard/galleries">← All galleries</Link>
|
||||
<span>/</span>
|
||||
<span>{isNew ? 'New gallery' : draft.title || 'Untitled gallery'}</span>
|
||||
</div>
|
||||
<div className="editor-header__actions">
|
||||
{gallery && (
|
||||
<Link className="editor-button editor-button--quiet" to={`/preview/${gallery.id}`}>
|
||||
Preview <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className="editor-button editor-button--quiet"
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
<button
|
||||
className="editor-button editor-button--accent"
|
||||
type="button"
|
||||
onClick={() => void publish()}
|
||||
disabled={publishing}
|
||||
>
|
||||
{publishing
|
||||
? 'Publishing...'
|
||||
: gallery?.status === 'published'
|
||||
? 'Republish'
|
||||
: 'Publish gallery'}{' '}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="editor-titlebar">
|
||||
<div>
|
||||
<p className="studio-kicker">
|
||||
{isNew ? 'New delivery' : `Editing / ${gallery?.status || 'draft'}`}
|
||||
</p>
|
||||
<h1 className="studio-display">
|
||||
{isNew ? (
|
||||
<>
|
||||
A new <em>story.</em>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{draft.title || 'Untitled'} <em>gallery.</em>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
{gallery?.status === 'published' && (
|
||||
<span className="editor-live">
|
||||
<i /> Live at /g/{gallery.slug}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||
{notice && <p className="studio-alert studio-alert--success">{notice}</p>}
|
||||
|
||||
<form className="editor-layout" onSubmit={submit}>
|
||||
<div className="editor-maincol">
|
||||
<section className="editor-section editor-section--first">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">01 / The introduction</p>
|
||||
<h2>Give it a name.</h2>
|
||||
<p>This is the first thing your client will see.</p>
|
||||
</div>
|
||||
<div className="editor-fields editor-fields--two">
|
||||
<label>
|
||||
Gallery title
|
||||
<input
|
||||
value={draft.title}
|
||||
onChange={(event) => setField('title', event.target.value)}
|
||||
placeholder="Emma & James — Wedding"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Client name
|
||||
<input
|
||||
value={draft.clientName}
|
||||
onChange={(event) => setField('clientName', event.target.value)}
|
||||
placeholder="Emma & James"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="editor-fields__full">
|
||||
A note about this gallery
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => setField('description', event.target.value)}
|
||||
placeholder="A few words to set the scene..."
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="editor-section">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">02 / The work</p>
|
||||
<h2>Bring it all in.</h2>
|
||||
<p>
|
||||
Originals stay private in object storage. Previews are prepared in the background.
|
||||
</p>
|
||||
</div>
|
||||
{gallery ? (
|
||||
<UploadDropzone
|
||||
galleryId={gallery.id}
|
||||
onMedia={(item) =>
|
||||
setGallery((current) =>
|
||||
current ? { ...current, media: [...current.media, item] } : current,
|
||||
)
|
||||
}
|
||||
onRefresh={() => void refreshGallery()}
|
||||
/>
|
||||
) : (
|
||||
<div className="editor-locked">
|
||||
<span>01</span>
|
||||
<p>Save the gallery details above to start uploading work.</p>
|
||||
</div>
|
||||
)}
|
||||
{gallery && (
|
||||
<div className="editor-media-grid">
|
||||
{gallery.media.map((item, index) => (
|
||||
<EditorMediaTile
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
total={gallery.media.length}
|
||||
cover={draft.coverMediaId === item.id}
|
||||
onCover={() => setField('coverMediaId', item.id)}
|
||||
onMove={(direction) => void moveMedia(item, direction)}
|
||||
onDelete={() => void removeMedia(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{gallery && gallery.media.length === 0 && (
|
||||
<div className="editor-media-empty">
|
||||
Your uploaded photographs will take center stage here.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="editor-aside">
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">03 / Client controls</p>
|
||||
<h2>Set the boundaries.</h2>
|
||||
</div>
|
||||
<div className="toggle-list">
|
||||
<Toggle
|
||||
label="Downloads"
|
||||
hint="Let clients save the originals."
|
||||
checked={draft.downloadsEnabled}
|
||||
onChange={(value) => setField('downloadsEnabled', value)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Favorites"
|
||||
hint="Let clients mark their favorites."
|
||||
checked={draft.favoritesEnabled}
|
||||
onChange={(value) => setField('favoritesEnabled', value)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Download all"
|
||||
hint="Offer a single gallery ZIP."
|
||||
checked={draft.downloadAllEnabled}
|
||||
onChange={(value) => setField('downloadAllEnabled', value)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Watermark previews"
|
||||
hint="Add a light overlay to previews."
|
||||
checked={draft.watermarkEnabled}
|
||||
onChange={(value) => setField('watermarkEnabled', value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="editor-field-single">
|
||||
Gallery password{' '}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Leave blank for none"
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearPassword}
|
||||
onChange={(event) => setClearPassword(event.target.checked)}
|
||||
/>
|
||||
<span>Remove existing password</span>
|
||||
</label>
|
||||
<label className="editor-field-single">
|
||||
Gallery expires{' '}
|
||||
<input
|
||||
type="date"
|
||||
value={draft.expiresAt}
|
||||
onChange={(event) => setField('expiresAt', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">04 / The atmosphere</p>
|
||||
<h2>Make it feel like you.</h2>
|
||||
</div>
|
||||
<div className="choice-label">Mode</div>
|
||||
<div className="choice-row">
|
||||
{(['light', 'dark'] as GalleryMode[]).map((mode) => (
|
||||
<button
|
||||
className={draft.themeMode === mode ? 'is-selected' : ''}
|
||||
type="button"
|
||||
key={mode}
|
||||
onClick={() => setField('themeMode', mode)}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="choice-label">Layout</div>
|
||||
<div className="choice-row choice-row--wrap">
|
||||
{(['editorial', 'masonry', 'grid'] as GalleryLayout[]).map((layout) => (
|
||||
<button
|
||||
className={draft.layout === layout ? 'is-selected' : ''}
|
||||
type="button"
|
||||
key={layout}
|
||||
onClick={() => setField('layout', layout)}
|
||||
>
|
||||
{layout}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="editor-color-field">
|
||||
Accent color{' '}
|
||||
<span>
|
||||
<input
|
||||
type="color"
|
||||
value={draft.accent}
|
||||
onChange={(event) => setField('accent', event.target.value)}
|
||||
/>
|
||||
<code>{draft.accent}</code>
|
||||
</span>
|
||||
</label>
|
||||
<div className="choice-label">Type</div>
|
||||
<div className="choice-row">
|
||||
<button
|
||||
className={draft.font === 'serif' ? 'is-selected' : ''}
|
||||
type="button"
|
||||
onClick={() => setField('font', 'serif')}
|
||||
>
|
||||
Editorial
|
||||
</button>
|
||||
<button
|
||||
className={draft.font === 'sans' ? 'is-selected' : ''}
|
||||
type="button"
|
||||
onClick={() => setField('font', 'sans')}
|
||||
>
|
||||
Clean
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">05 / Your signature</p>
|
||||
<h2>Leave your mark.</h2>
|
||||
</div>
|
||||
<label className="editor-field-single">
|
||||
Studio name{' '}
|
||||
<input
|
||||
value={draft.studioName}
|
||||
onChange={(event) => setField('studioName', event.target.value)}
|
||||
placeholder={user?.name || 'Your studio'}
|
||||
/>
|
||||
</label>
|
||||
<label className="editor-field-single">
|
||||
Tagline{' '}
|
||||
<input
|
||||
value={draft.tagline}
|
||||
onChange={(event) => setField('tagline', event.target.value)}
|
||||
placeholder="Photographs for keeps."
|
||||
/>
|
||||
</label>
|
||||
<label className="editor-field-single">
|
||||
Website{' '}
|
||||
<input
|
||||
type="url"
|
||||
value={draft.websiteUrl}
|
||||
onChange={(event) => setField('websiteUrl', event.target.value)}
|
||||
placeholder="https://yourstudio.com"
|
||||
/>
|
||||
</label>
|
||||
<label className="editor-field-single">
|
||||
Instagram{' '}
|
||||
<input
|
||||
value={draft.instagramUrl}
|
||||
onChange={(event) => setField('instagramUrl', event.target.value)}
|
||||
placeholder="https://instagram.com/yourstudio"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</aside>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="toggle-line">
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<small>{hint}</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<i aria-hidden="true" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorMediaTile({
|
||||
item,
|
||||
index,
|
||||
total,
|
||||
cover,
|
||||
onCover,
|
||||
onMove,
|
||||
onDelete,
|
||||
}: {
|
||||
item: MediaItem;
|
||||
index: number;
|
||||
total: number;
|
||||
cover: boolean;
|
||||
onCover: () => void;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const isVideo = item.mimeType.startsWith('video/');
|
||||
return (
|
||||
<article className={`editor-media-tile ${cover ? 'is-cover' : ''}`}>
|
||||
<div className="editor-media-tile__image">
|
||||
{item.previewUrl ? (
|
||||
<img src={item.previewUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="editor-media-tile__placeholder">
|
||||
<span>{item.processingStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
{isVideo && (
|
||||
<span className="editor-media-tile__video">
|
||||
VIDEO {formatDuration(item.durationSeconds)}
|
||||
</span>
|
||||
)}
|
||||
{cover && <span className="editor-media-tile__cover">Cover</span>}
|
||||
</div>
|
||||
<div className="editor-media-tile__body">
|
||||
<strong>{item.originalFilename}</strong>
|
||||
<small>
|
||||
{formatBytes(item.fileSize)} / {item.processingStatus.toLowerCase()}
|
||||
</small>
|
||||
</div>
|
||||
<div className="editor-media-tile__actions">
|
||||
<button type="button" onClick={onCover}>
|
||||
{cover ? 'Cover image' : 'Set cover'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => onMove(-1)}
|
||||
aria-label="Move media earlier"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === total - 1}
|
||||
onClick={() => onMove(1)}
|
||||
aria-label="Move media later"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button type="button" onClick={onDelete} aria-label={`Delete ${item.originalFilename}`}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="studio-page studio-page--placeholder">
|
||||
<p className="studio-kicker">Workspace / soon</p>
|
||||
<h1 className="studio-display">
|
||||
{title} <em>is coming.</em>
|
||||
</h1>
|
||||
<p className="studio-page__lede">{description}</p>
|
||||
<Link className="inline-link" to="/dashboard">
|
||||
Return to overview <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
export interface DevDiagnostics {
|
||||
environment: string;
|
||||
now: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
database: {
|
||||
driver: string;
|
||||
connected: boolean;
|
||||
error?: string;
|
||||
};
|
||||
storage: {
|
||||
provider: string;
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
secure: boolean;
|
||||
reachable: boolean;
|
||||
error?: string;
|
||||
};
|
||||
http: {
|
||||
corsOrigins: string[];
|
||||
cookieSecure: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevStorageCheck {
|
||||
ok: boolean;
|
||||
bytes?: number;
|
||||
contentType?: string;
|
||||
step?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export type GalleryStatus = 'draft' | 'published' | 'archived';
|
||||
export type ProcessingStatus = 'UPLOADING' | 'PROCESSING' | 'READY' | 'FAILED';
|
||||
export type GalleryLayout = 'grid' | 'masonry' | 'editorial';
|
||||
export type GalleryMode = 'light' | 'dark';
|
||||
|
||||
export interface ThemeConfig {
|
||||
mode?: GalleryMode;
|
||||
layout?: GalleryLayout;
|
||||
accent?: string;
|
||||
font?: 'sans' | 'serif';
|
||||
}
|
||||
|
||||
export interface BrandingConfig {
|
||||
studioName?: string;
|
||||
tagline?: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
instagramUrl?: string;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
processingStatus: ProcessingStatus;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSeconds?: number;
|
||||
sortOrder: number;
|
||||
thumbnailUrl?: string;
|
||||
previewUrl?: string;
|
||||
originalUrl?: string;
|
||||
favorited?: boolean;
|
||||
}
|
||||
|
||||
export interface GallerySummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
clientName: string;
|
||||
description: string;
|
||||
status: GalleryStatus;
|
||||
downloadsEnabled: boolean;
|
||||
favoritesEnabled: boolean;
|
||||
downloadAllEnabled: boolean;
|
||||
watermarkEnabled: boolean;
|
||||
expiresAt?: string;
|
||||
coverMediaId?: string;
|
||||
coverUrl?: string;
|
||||
themeConfig: ThemeConfig;
|
||||
brandingConfig: BrandingConfig;
|
||||
createdAt: string;
|
||||
publishedAt?: string;
|
||||
photoCount: number;
|
||||
videoCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface GalleryDetail extends GallerySummary {
|
||||
media: MediaItem[];
|
||||
}
|
||||
|
||||
export interface PublicGallery {
|
||||
slug: string;
|
||||
title: string;
|
||||
clientName: string;
|
||||
description: string;
|
||||
themeConfig: ThemeConfig;
|
||||
brandingConfig: BrandingConfig;
|
||||
downloadsEnabled?: boolean;
|
||||
favoritesEnabled?: boolean;
|
||||
downloadAllEnabled?: boolean;
|
||||
watermarkEnabled?: boolean;
|
||||
expiresAt?: string;
|
||||
preview?: boolean;
|
||||
requiresPassword: boolean;
|
||||
cover?: MediaItem;
|
||||
media: MediaItem[];
|
||||
}
|
||||
|
||||
export interface DownloadJob {
|
||||
jobId: string;
|
||||
status: 'QUEUED' | 'PROCESSING' | 'READY' | 'FAILED';
|
||||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface GiftItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
sortOrder: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Gift {
|
||||
id: string;
|
||||
slug: string;
|
||||
recipientName: string;
|
||||
senderName: string;
|
||||
title: string;
|
||||
introMessage: string;
|
||||
revealMessage: string;
|
||||
items: GiftItem[];
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user