This commit is contained in:
2026-08-22 02:59:16 +02:00
commit 6a5bb1d699
100 changed files with 17409 additions and 0 deletions
+99
View File
@@ -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"
>
&#8599;
</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">
&#8599;
</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">&#8599;</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}`}
>
&#215;
</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}`}
>
&#215;
</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">&#8595;</span>
</button>
)}
<span className="client-gallery__edition">{gallery.clientName}</span>
</div>
</header>
<main>
<section className="client-hero">
<div className="client-hero__copy">
<p className="client-kicker">A collection for {gallery.clientName}</p>
<h1 className="client-display">{gallery.title}</h1>
{gallery.description && (
<p className="client-hero__description">{gallery.description}</p>
)}
</div>
<div className="client-hero__cover">
{cover?.previewUrl ? (
<img src={cover.previewUrl} alt="" />
) : (
<div className="client-hero__fallback">
<span>{gallery.clientName.slice(0, 1)}</span>
</div>
)}
<div className="client-hero__cover-caption">
<span>Open to remember</span>
<span>{items.length} pieces</span>
</div>
</div>
<div className="client-hero__scroll">
<span /> Scroll to wander
</div>
</section>
<section className="client-work-section">
<div className="client-section-heading">
<div>
<p className="client-kicker">The collection</p>
<h2 className="client-display">
The day, <em>held still.</em>
</h2>
</div>
<span>
{photos.length} photographs /{' '}
{items.filter((item) => item.mimeType.startsWith('video/')).length} films
</span>
</div>
{items.length === 0 ? (
<div className="client-empty">
<span>+</span>
<p>Your gallery is still being arranged.</p>
</div>
) : (
<div className="client-media-grid">
{items.map((item) => (
<MediaCard
key={item.id}
item={item}
gallery={gallery}
onOpen={() => openPhoto(item)}
onFavorite={() => void toggleFavorite(item)}
onDownload={() => void downloadOne(item)}
/>
))}
</div>
)}
</section>
<section className="client-download-section">
<div className="client-download-section__copy">
<p className="client-kicker">Take it with you</p>
<h2 className="client-display">
The moments are
<br />
<em>yours to keep.</em>
</h2>
<p>Save the full-resolution photographs and revisit this chapter whenever you like.</p>
</div>
{gallery.downloadsEnabled !== false && (
<button
className="client-download-button"
type="button"
onClick={() => void downloadAll()}
disabled={downloadJob?.status === 'PROCESSING' || downloadJob?.status === 'QUEUED'}
>
<span>
{downloadJob?.status === 'PROCESSING'
? 'Preparing your gallery'
: downloadJob?.status === 'READY'
? 'Download ready'
: 'Download all originals'}
</span>
<span aria-hidden="true">&#8595;</span>
</button>
)}
{downloadJob?.status === 'READY' && downloadJob.url && (
<a
className="client-download-ready"
href={downloadJob.url}
target="_blank"
rel="noreferrer"
>
Your ZIP is ready <span aria-hidden="true">&#8599;</span>
</a>
)}
{downloadJob?.status === 'FAILED' && (
<p className="client-download-error">
{downloadJob.error || 'The download could not be prepared.'}
</p>
)}
</section>
</main>
<footer className="client-footer">
<div>
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
<div>
<strong>{branding.studioName || 'Your studio'}</strong>
<small>{branding.tagline || 'Photographs for keeps.'}</small>
</div>
</div>
<div className="client-footer__links">
{branding.websiteUrl && (
<a href={branding.websiteUrl} target="_blank" rel="noreferrer">
Website &#8599;
</a>
)}
{branding.instagramUrl && (
<a href={branding.instagramUrl} target="_blank" rel="noreferrer">
Instagram &#8599;
</a>
)}
</div>
<span className="client-footer__credit">Delivered with intention</span>
</footer>
<AnimatePresence>
{viewerIndex !== null && (
<PhotoViewer
items={photos}
index={viewerIndex}
onClose={() => setViewerIndex(null)}
onChange={setViewerIndex}
onFavorite={(item) => void toggleFavorite(item)}
onDownload={(item) => void downloadOne(item)}
/>
)}
</AnimatePresence>
</div>
);
}
function MediaCard({
item,
gallery,
onOpen,
onFavorite,
onDownload,
}: {
item: MediaItem;
gallery: PublicGallery;
onOpen: () => void;
onFavorite: () => void;
onDownload: () => void;
}) {
const isVideo = item.mimeType.startsWith('video/');
const ready = item.processingStatus === 'READY';
return (
<article
className={`client-media-card ${isVideo ? 'client-media-card--video' : ''} ${gallery.watermarkEnabled ? 'client-media-card--watermarked' : ''}`}
>
{isVideo ? (
<div className="client-media-card__video-wrap">
{ready && item.previewUrl ? (
<video
controls
playsInline
preload="metadata"
poster={gallery.cover?.previewUrl}
src={item.previewUrl}
/>
) : (
<div className="client-media-card__processing">
{item.processingStatus.toLowerCase()}
</div>
)}
<span className="client-media-card__video-label">
Film {formatDuration(item.durationSeconds)}
</span>
</div>
) : (
<button
className="client-media-card__image"
type="button"
onClick={onOpen}
disabled={!ready}
>
{ready && (item.thumbnailUrl || item.previewUrl) ? (
<img
src={item.thumbnailUrl || item.previewUrl}
alt={item.originalFilename}
loading="lazy"
/>
) : (
<div className="client-media-card__processing">
{item.processingStatus.toLowerCase()}
</div>
)}
{gallery.watermarkEnabled && (
<span className="client-watermark">
{gallery.brandingConfig.studioName || 'Preview'}
</span>
)}
<span className="client-media-card__open" aria-hidden="true">
&#8599;
</span>
</button>
)}
<div className="client-media-card__footer">
<span>{item.originalFilename}</span>
{gallery.favoritesEnabled !== false && (
<button
type="button"
className={item.favorited ? 'is-favorited' : ''}
onClick={onFavorite}
aria-label={item.favorited ? 'Remove favorite' : 'Favorite photo'}
>
{item.favorited ? '♥' : '♡'}
</button>
)}
{gallery.downloadsEnabled !== false && (
<button type="button" onClick={onDownload} aria-label="Download original">
&#8595;
</button>
)}
</div>
</article>
);
}
@@ -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">&#8599;</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">
&#215;
</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"
>
&#8592;
</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"
>
&#8594;
</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)}>
&#8595; <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>
);
}
+36
View File
@@ -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">&#8599;</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">&#10022;</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">&#8599;</span>
</button>
</div>
</Background>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+24
View File
@@ -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">&#8599;</span>
</motion.button>
)}
</div>
</div>
</Background>
);
}
+25
View File
@@ -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">
&ldquo;
</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">
&#9654;
</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);
+11
View File
@@ -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;
}
+312
View File
@@ -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' });
}
+24
View File
@@ -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}`;
}
+11
View File
@@ -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>,
);
+33
View File
@@ -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} />;
}
+88
View File
@@ -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>
);
}
+82
View File
@@ -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">&#8599;</span>
</button>
</form>
<p className="auth-card__footer">
New to Northline? <Link to="/register">Create an account</Link>
</p>
</div>
</AuthLayout>
);
}
+19
View File
@@ -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">&#8599;</span>
</Link>
</div>
);
}
+66
View File
@@ -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>
);
}
+91
View File
@@ -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">&#8599;</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">&#8599;</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">&#8599;</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>
);
}
+299
View File
@@ -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>&#8594;</i>
<span>
02 <strong>Presigned PUT</strong>
<small>Browser to MinIO</small>
</span>
<i>&#8594;</i>
<span>
03 <strong>Complete</strong>
<small>Stat object and queue processing</small>
</span>
<i>&#8594;</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">&#8599;</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">&#8592; 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">&#8599;</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">&#8599;</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"
>
&#8593;
</button>
<button
type="button"
disabled={index === total - 1}
onClick={() => onMove(1)}
aria-label="Move media later"
>
&#8595;
</button>
<button type="button" onClick={onDelete} aria-label={`Delete ${item.originalFilename}`}>
&#215;
</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">&#8599;</span>
</Link>
</div>
</DashboardLayout>
);
}
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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;
}
+87
View File
@@ -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;
}
+20
View File
@@ -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[];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />