ai slop ah
This commit is contained in:
@@ -11,6 +11,7 @@ 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 SettingsPage from './pages/dashboard/SettingsPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
export default function App() {
|
||||
@@ -67,7 +68,7 @@ export default function App() {
|
||||
<RequireAuth>
|
||||
<PlaceholderPage
|
||||
title="Storage"
|
||||
description="A calm view of every original, preview, and byte in your studio is on its way."
|
||||
description="Storage module unavailable."
|
||||
/>
|
||||
</RequireAuth>
|
||||
}
|
||||
@@ -76,10 +77,7 @@ export default function App() {
|
||||
path="/dashboard/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<PlaceholderPage
|
||||
title="Settings"
|
||||
description="Your studio identity and delivery defaults will have a home here soon."
|
||||
/>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -6,16 +6,12 @@ export function AuthLayout({ children }: { children: ReactNode }) {
|
||||
<div className="auth-shell__texture" aria-hidden="true" />
|
||||
<div className="auth-shell__brand">
|
||||
<span className="platform-mark">N</span>
|
||||
<span>Northline</span>
|
||||
<span>Noah Bianchi</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>
|
||||
<p className="platform-kicker">Private delivery system</p>
|
||||
<h1 className="platform-display">Studio Access</h1>
|
||||
<p>Photographer gallery console for client delivery.</p>
|
||||
</div>
|
||||
<div className="auth-shell__panel">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
<Link className="studio-logo" to="/dashboard">
|
||||
<span className="studio-logo__mark">N</span>
|
||||
<span>
|
||||
Northline
|
||||
<small>delivery studio</small>
|
||||
Noah Bianchi
|
||||
<small>photography studio</small>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="studio-sidebar__label">Workspace</div>
|
||||
@@ -48,8 +48,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
<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>
|
||||
<strong>System Status</strong>
|
||||
<small>Delivery console active.</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="studio-account">
|
||||
@@ -73,7 +73,7 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
<div className="studio-mobilebar">
|
||||
<Link className="studio-logo" to="/dashboard">
|
||||
<span className="studio-logo__mark">N</span>
|
||||
<span>Northline</span>
|
||||
<span>Noah Bianchi</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -65,7 +65,7 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
|
||||
});
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
'northline:last-upload-error',
|
||||
'studio:last-upload-error',
|
||||
JSON.stringify({ at: new Date().toISOString(), filename: file.name, message }),
|
||||
);
|
||||
} catch {
|
||||
@@ -117,7 +117,7 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
|
||||
<span className="upload-zone__orb" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span className="upload-zone__title">Drop finished work here</span>
|
||||
<span className="upload-zone__title">Drop files here</span>
|
||||
<span className="upload-zone__hint">
|
||||
or click to browse / JPG, PNG, WEBP, HEIC, MP4, MOV
|
||||
</span>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
const cover = gallery.cover || photos[0];
|
||||
const theme = gallery.themeConfig || {};
|
||||
const branding = gallery.brandingConfig || {};
|
||||
const style = { '--gallery-accent': theme.accent || '#a85e55' } as CSSProperties;
|
||||
const style = { '--gallery-accent': theme.accent || '#bc6655' } as CSSProperties;
|
||||
|
||||
useEffect(() => setItems(gallery.media), [gallery.media]);
|
||||
|
||||
@@ -109,8 +109,8 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
>
|
||||
{gallery.preview && (
|
||||
<div className="preview-ribbon">
|
||||
<span>Preview mode</span>
|
||||
<span>This is how your clients will see it</span>
|
||||
<span>Preview Mode</span>
|
||||
<span>Client View</span>
|
||||
</div>
|
||||
)}
|
||||
<header className="client-gallery__nav">
|
||||
@@ -146,7 +146,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
<main>
|
||||
<section className="client-hero">
|
||||
<div className="client-hero__copy">
|
||||
<p className="client-kicker">A collection for {gallery.clientName}</p>
|
||||
<p className="client-kicker">Gallery for {gallery.clientName}</p>
|
||||
<h1 className="client-display">{gallery.title}</h1>
|
||||
{gallery.description && (
|
||||
<p className="client-hero__description">{gallery.description}</p>
|
||||
@@ -161,21 +161,21 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="client-hero__cover-caption">
|
||||
<span>Open to remember</span>
|
||||
<span>Cover Image</span>
|
||||
<span>{items.length} pieces</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-hero__scroll">
|
||||
<span /> Scroll to wander
|
||||
<span /> Scroll for media
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="client-work-section">
|
||||
<div className="client-section-heading">
|
||||
<div>
|
||||
<p className="client-kicker">The collection</p>
|
||||
<p className="client-kicker">Gallery Media</p>
|
||||
<h2 className="client-display">
|
||||
The day, <em>held still.</em>
|
||||
Photos <em>& video.</em>
|
||||
</h2>
|
||||
</div>
|
||||
<span>
|
||||
@@ -186,7 +186,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
{items.length === 0 ? (
|
||||
<div className="client-empty">
|
||||
<span>+</span>
|
||||
<p>Your gallery is still being arranged.</p>
|
||||
<p>No media uploaded.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="client-media-grid">
|
||||
@@ -206,13 +206,13 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
|
||||
<section className="client-download-section">
|
||||
<div className="client-download-section__copy">
|
||||
<p className="client-kicker">Take it with you</p>
|
||||
<p className="client-kicker">Downloads</p>
|
||||
<h2 className="client-display">
|
||||
The moments are
|
||||
Download
|
||||
<br />
|
||||
<em>yours to keep.</em>
|
||||
<em>originals.</em>
|
||||
</h2>
|
||||
<p>Save the full-resolution photographs and revisit this chapter whenever you like.</p>
|
||||
<p>Download the original files from this gallery.</p>
|
||||
</div>
|
||||
{gallery.downloadsEnabled !== false && (
|
||||
<button
|
||||
@@ -238,7 +238,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Your ZIP is ready <span aria-hidden="true">↗</span>
|
||||
ZIP ready <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
)}
|
||||
{downloadJob?.status === 'FAILED' && (
|
||||
@@ -254,7 +254,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
<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>
|
||||
<small>{branding.tagline || 'Client Delivery'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-footer__links">
|
||||
@@ -269,7 +269,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span className="client-footer__credit">Delivered with intention</span>
|
||||
<span className="client-footer__credit">Noah Bianchi</span>
|
||||
</footer>
|
||||
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -27,7 +27,6 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
|
||||
|
||||
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'}
|
||||
@@ -38,9 +37,9 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
|
||||
<span className="client-lock-screen__lock" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<p className="client-kicker">A private delivery</p>
|
||||
<p className="client-kicker">Protected Gallery</p>
|
||||
<h1 className="client-display">{gallery.title}</h1>
|
||||
<p>This gallery was made for {gallery.clientName}. Enter the password to open it.</p>
|
||||
<p>Password required for {gallery.clientName}.</p>
|
||||
<form className="client-password-form" onSubmit={submit}>
|
||||
<label className="sr-only" htmlFor="gallery-password">
|
||||
Gallery password
|
||||
@@ -65,7 +64,7 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
|
||||
</form>
|
||||
{error && <p className="client-form-error">{error}</p>}
|
||||
</div>
|
||||
<p className="client-lock-screen__footer">The work is waiting inside.</p>
|
||||
<p className="client-lock-screen__footer">Access required.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import type { Gift } from '../../types/gift';
|
||||
import { Background } from './Background';
|
||||
import { Confetti } from './Confetti';
|
||||
import { GiftItem } from './GiftItem';
|
||||
import { IntroScreen } from './IntroScreen';
|
||||
import { ProgressIndicator } from './ProgressIndicator';
|
||||
import { RevealScreen } from './RevealScreen';
|
||||
|
||||
type ExperienceStage = 'INTRO' | 'OPEN_PROMPT' | 'REVEAL' | 'CONTENT' | 'FINISH';
|
||||
|
||||
interface GiftExperienceProps {
|
||||
gift: Gift;
|
||||
}
|
||||
|
||||
const screenTransition = {
|
||||
duration: 0.65,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
};
|
||||
|
||||
export function GiftExperience({ gift }: GiftExperienceProps) {
|
||||
const [stage, setStage] = useState<ExperienceStage>('INTRO');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'REVEAL') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setStage(gift.items.length > 0 ? 'CONTENT' : 'FINISH');
|
||||
}, 1550);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [gift.items.length, stage]);
|
||||
|
||||
function showNextItem() {
|
||||
if (activeIndex < gift.items.length - 1) {
|
||||
setActiveIndex((index) => index + 1);
|
||||
return;
|
||||
}
|
||||
setStage('FINISH');
|
||||
}
|
||||
|
||||
function replay() {
|
||||
setActiveIndex(0);
|
||||
setStage('INTRO');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gift-experience" aria-live="polite">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{stage === 'INTRO' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="intro"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<IntroScreen gift={gift} onContinue={() => setStage('OPEN_PROMPT')} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === 'OPEN_PROMPT' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="open-prompt"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<RevealScreen
|
||||
recipientName={gift.recipientName}
|
||||
isRevealing={false}
|
||||
onReveal={() => setStage('REVEAL')}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === 'REVEAL' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="reveal"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<RevealScreen
|
||||
recipientName={gift.recipientName}
|
||||
isRevealing
|
||||
onReveal={() => undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === 'CONTENT' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="content"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<Background tone="content">
|
||||
<div className="content-screen">
|
||||
<div className="content-screen__topline">
|
||||
<span className="brand-lockup brand-lockup--ink">
|
||||
<span>little</span>
|
||||
<span>something</span>
|
||||
</span>
|
||||
<span className="topline-note">a few things for you</span>
|
||||
</div>
|
||||
|
||||
<div className="content-screen__intro">
|
||||
<p className="eyebrow eyebrow--ink">chapter {activeIndex + 1}</p>
|
||||
<h1 className="display">
|
||||
For the moments
|
||||
<br />
|
||||
<em>worth keeping.</em>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ProgressIndicator current={activeIndex} total={gift.items.length} />
|
||||
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{gift.items[activeIndex] && (
|
||||
<motion.div
|
||||
className="content-screen__item"
|
||||
key={gift.items[activeIndex].id}
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -24 }}
|
||||
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<GiftItem item={gift.items[activeIndex]} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="content-screen__bottomline">
|
||||
<span>for {gift.recipientName}</span>
|
||||
<button className="next-action" type="button" onClick={showNextItem}>
|
||||
<span>
|
||||
{activeIndex === gift.items.length - 1 ? 'Keep this moment' : 'Next memory'}
|
||||
</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Background>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{stage === 'FINISH' && (
|
||||
<motion.div
|
||||
className="experience-screen"
|
||||
key="finish"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={screenTransition}
|
||||
>
|
||||
<Background tone="finish">
|
||||
<Confetti />
|
||||
<div className="finish-screen">
|
||||
<div className="finish-screen__topline">
|
||||
<span className="brand-lockup brand-lockup--light">
|
||||
<span>little</span>
|
||||
<span>something</span>
|
||||
</span>
|
||||
<span className="topline-note">the end, for now</span>
|
||||
</div>
|
||||
<div className="finish-screen__center">
|
||||
<motion.div
|
||||
className="finish-screen__spark"
|
||||
initial={{ scale: 0, rotate: -20 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.25, type: 'spring', stiffness: 180, damping: 12 }}
|
||||
>
|
||||
<span aria-hidden="true">✦</span>
|
||||
</motion.div>
|
||||
<p className="eyebrow eyebrow--quiet">a final note</p>
|
||||
<h1 className="display">
|
||||
Made with love,
|
||||
<br />
|
||||
<em>{gift.recipientName}.</em>
|
||||
</h1>
|
||||
<p className="finish-screen__message">{gift.revealMessage}</p>
|
||||
<p className="finish-screen__signature">
|
||||
Always,
|
||||
<br />
|
||||
<strong>{gift.senderName}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-action ghost-action--light finish-screen__replay"
|
||||
type="button"
|
||||
onClick={replay}
|
||||
>
|
||||
<span>See it again</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</Background>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { Background } from './Background';
|
||||
|
||||
interface RevealScreenProps {
|
||||
recipientName: string;
|
||||
isRevealing: boolean;
|
||||
onReveal: () => void;
|
||||
}
|
||||
|
||||
export function RevealScreen({ recipientName, isRevealing, onReveal }: RevealScreenProps) {
|
||||
return (
|
||||
<Background tone="reveal">
|
||||
<div className="reveal-screen">
|
||||
<div className="reveal-screen__topline">
|
||||
<span className="brand-lockup brand-lockup--light">
|
||||
<span>little</span>
|
||||
<span>something</span>
|
||||
</span>
|
||||
<span className="topline-note">just for {recipientName}</span>
|
||||
</div>
|
||||
|
||||
<div className="reveal-screen__center">
|
||||
<motion.div
|
||||
className="reveal-orbit reveal-orbit--outer"
|
||||
animate={isRevealing ? { rotate: 360, scale: 1.2 } : { rotate: 0, scale: 1 }}
|
||||
transition={{ duration: 1.5, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="reveal-orbit reveal-orbit--inner"
|
||||
animate={isRevealing ? { rotate: -360, scale: 0.76 } : { rotate: 0, scale: 1 }}
|
||||
transition={{ duration: 1.35, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="reveal-seal"
|
||||
animate={
|
||||
isRevealing
|
||||
? { scale: [1, 1.06, 0.2], opacity: [1, 1, 0], rotate: [0, -8, 18] }
|
||||
: { scale: 1, opacity: 1, rotate: 0 }
|
||||
}
|
||||
transition={{ duration: 1.25, times: [0, 0.48, 1], ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<span className="reveal-seal__halo" />
|
||||
<span className="reveal-seal__mark">ls</span>
|
||||
<span className="reveal-seal__label">open gently</span>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
className="reveal-screen__copy"
|
||||
key={isRevealing ? 'revealing' : 'prompt'}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
>
|
||||
<p className="eyebrow eyebrow--quiet">
|
||||
{isRevealing ? 'here it comes' : 'one tiny step'}
|
||||
</p>
|
||||
<h1 className="display">
|
||||
{isRevealing ? 'Making room for a little magic.' : 'There is something inside.'}
|
||||
</h1>
|
||||
<p>
|
||||
{isRevealing
|
||||
? 'Take a breath. Your moment is opening.'
|
||||
: 'No rush. This one was made to be opened slowly.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{!isRevealing && (
|
||||
<motion.button
|
||||
className="ghost-action ghost-action--light"
|
||||
type="button"
|
||||
onClick={onReveal}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span>Unwrap the moment</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { GiftItem as GiftItemData } from '../../types/gift';
|
||||
|
||||
interface TextItemProps {
|
||||
item: GiftItemData;
|
||||
}
|
||||
|
||||
export function TextItem({ item }: TextItemProps) {
|
||||
const eyebrow =
|
||||
typeof item.metadata?.eyebrow === 'string' ? item.metadata.eyebrow : 'A note from me';
|
||||
|
||||
return (
|
||||
<div className="gift-text-item">
|
||||
<div className="gift-text-item__quote" aria-hidden="true">
|
||||
“
|
||||
</div>
|
||||
<p className="eyebrow eyebrow--ink">{eyebrow}</p>
|
||||
{item.title && <h2 className="display">{item.title}</h2>}
|
||||
<p className="gift-text-item__body">{item.text || 'A little note, just because.'}</p>
|
||||
<div className="gift-text-item__signature" aria-hidden="true">
|
||||
<span />
|
||||
<span>with love</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { GiftItem as GiftItemData } from '../../types/gift';
|
||||
|
||||
interface VideoItemProps {
|
||||
item: GiftItemData;
|
||||
}
|
||||
|
||||
export function VideoItem({ item }: VideoItemProps) {
|
||||
return (
|
||||
<div className="gift-video-item">
|
||||
{item.mediaUrl ? (
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={item.metadata?.poster as string | undefined}
|
||||
>
|
||||
<source src={item.mediaUrl} />
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
) : (
|
||||
<div className="gift-video-item__placeholder">
|
||||
<span className="video-play" aria-hidden="true">
|
||||
▶
|
||||
</span>
|
||||
<span>Your video goes here</span>
|
||||
</div>
|
||||
)}
|
||||
{item.text && <p>{item.text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-26
@@ -1,4 +1,3 @@
|
||||
import type { Gift } from '../types/gift';
|
||||
import type { DevDiagnostics, DevStorageCheck } from '../types/dev';
|
||||
import type {
|
||||
DownloadJob,
|
||||
@@ -54,31 +53,6 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<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;
|
||||
@@ -110,6 +84,21 @@ export async function logout(): Promise<void> {
|
||||
await request('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function updateProfile(name: string, email: string): Promise<User> {
|
||||
const response = await request<{ user: User }>('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name, email }),
|
||||
});
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await request('/api/auth/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getGalleries(): Promise<GallerySummary[]> {
|
||||
const response = await request<{ galleries: GallerySummary[] }>('/api/galleries');
|
||||
return response.galleries;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -34,17 +34,13 @@ export default function LoginPage() {
|
||||
<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>
|
||||
<p className="platform-kicker platform-kicker--accent">01 / Authenticate</p>
|
||||
<h2 className="platform-display">Sign In</h2>
|
||||
<p>Photographer console access.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
Email address
|
||||
Email:
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
@@ -54,7 +50,7 @@ export default function LoginPage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
Password:
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
@@ -69,12 +65,12 @@ export default function LoginPage() {
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
<span>{submitting ? 'Opening studio...' : 'Sign in'}</span>
|
||||
<span>{submitting ? 'Signing in...' : 'Sign In'}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-card__footer">
|
||||
New to Northline? <Link to="/register">Create an account</Link>
|
||||
No studio access? <Link to="/register">New account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -4,13 +4,13 @@ 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>
|
||||
<p className="client-kicker">404 / Gallery Not Found</p>
|
||||
<h1 className="client-display">
|
||||
This link took
|
||||
Gallery
|
||||
<br />
|
||||
<em>a wrong turn.</em>
|
||||
<em>not found.</em>
|
||||
</h1>
|
||||
<p>The gallery you are looking for may have moved or never existed.</p>
|
||||
<p>The requested gallery does not exist or is no longer available.</p>
|
||||
<Link className="client-download-ready" to="/login">
|
||||
Return to studio <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ClientLoading() {
|
||||
return (
|
||||
<div className="client-loading">
|
||||
<span className="client-loading__mark">N</span>
|
||||
<p>Preparing your gallery</p>
|
||||
<p>Loading gallery...</p>
|
||||
<span className="client-loading__line" />
|
||||
</div>
|
||||
);
|
||||
@@ -54,11 +54,11 @@ 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>
|
||||
<p className="client-kicker">Gallery Error</p>
|
||||
<h1 className="client-display">
|
||||
This gallery is
|
||||
Unable to load
|
||||
<br />
|
||||
<em>out of reach.</em>
|
||||
<em>gallery.</em>
|
||||
</h1>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
@@ -33,17 +33,13 @@ export default function RegisterPage() {
|
||||
<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>
|
||||
<p className="platform-kicker platform-kicker--accent">01 / Provision</p>
|
||||
<h2 className="platform-display">New Studio</h2>
|
||||
<p>Create a private delivery workspace.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
Studio or photographer name
|
||||
Studio name:
|
||||
<input
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
@@ -52,7 +48,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Email address
|
||||
Email:
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
@@ -62,7 +58,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
Password:
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
@@ -78,12 +74,12 @@ export default function RegisterPage() {
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
<span>{submitting ? 'Creating studio...' : 'Create account'}</span>
|
||||
<span>{submitting ? 'Creating account...' : 'Create Account'}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-card__footer">
|
||||
Already have an account? <Link to="/login">Sign in</Link>
|
||||
Existing access? <Link to="/login">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -43,43 +43,39 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
<p className="studio-kicker">{user?.name || 'Studio'} / Overview</p>
|
||||
<h1 className="studio-display">Dashboard</h1>
|
||||
<p className="studio-page__lede">Active delivery workspace.</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||
<span>New gallery</span>
|
||||
<span>New Gallery</span>
|
||||
<span aria-hidden="true">+</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="studio-stats" aria-label="Studio overview">
|
||||
<StudioStat
|
||||
label="Galleries"
|
||||
label="Total Galleries"
|
||||
value={String(galleries.length).padStart(2, '0')}
|
||||
note="All your work, in one place"
|
||||
note="Gallery Buffer"
|
||||
accent="coral"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Published"
|
||||
value={String(published).padStart(2, '0')}
|
||||
note="Currently out in the world"
|
||||
note="Live Deliveries"
|
||||
accent="violet"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Photographs"
|
||||
value={String(photos).padStart(2, '0')}
|
||||
note="Ready to be remembered"
|
||||
note="Media Records"
|
||||
accent="gold"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Storage used"
|
||||
label="Storage Used"
|
||||
value={formatBytes(storage)}
|
||||
note="Across every gallery"
|
||||
note="Object Storage"
|
||||
accent="ink"
|
||||
/>
|
||||
</section>
|
||||
@@ -87,11 +83,11 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
<p className="studio-kicker">06 / Recent activity</p>
|
||||
<h2 className="studio-heading">Recent Galleries</h2>
|
||||
</div>
|
||||
<Link className="inline-link" to="/dashboard/galleries">
|
||||
View all galleries <span aria-hidden="true">↗</span>
|
||||
View All <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||
@@ -105,11 +101,11 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
<h3>No galleries yet</h3>
|
||||
<p>No galleries. Create one to start.</p>
|
||||
</div>
|
||||
<Link className="inline-link" to="/dashboard/galleries/new">
|
||||
Create a gallery <span aria-hidden="true">↗</span>
|
||||
Create Gallery <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function DevPage() {
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
try {
|
||||
const raw = window.localStorage.getItem('northline:last-upload-error');
|
||||
const raw = window.localStorage.getItem('studio:last-upload-error');
|
||||
if (raw) setUploadError(JSON.parse(raw) as UploadErrorLog);
|
||||
} catch {
|
||||
setUploadError(null);
|
||||
@@ -70,12 +70,8 @@ export default function DevPage() {
|
||||
<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>
|
||||
<h1 className="studio-display">Dev Console</h1>
|
||||
<p className="studio-page__lede">Local runtime diagnostics and storage checks.</p>
|
||||
</div>
|
||||
<div className="dev-page__actions">
|
||||
<button
|
||||
@@ -218,7 +214,7 @@ export default function DevPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem('northline:last-upload-error');
|
||||
window.localStorage.removeItem('studio:last-upload-error');
|
||||
setUploadError(null);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -35,16 +35,12 @@ export default function GalleriesPage() {
|
||||
<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>
|
||||
<p className="studio-kicker">Workspace / Galleries</p>
|
||||
<h1 className="studio-display">Galleries</h1>
|
||||
<p className="studio-page__lede">All delivery records.</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||
<span>New gallery</span>
|
||||
<span>New Gallery</span>
|
||||
<span aria-hidden="true">+</span>
|
||||
</Link>
|
||||
</header>
|
||||
@@ -59,13 +55,11 @@ export default function GalleriesPage() {
|
||||
<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>
|
||||
<h3>No galleries yet</h3>
|
||||
<p>No galleries. Create one to start.</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--dark" to="/dashboard/galleries/new">
|
||||
<span>Create your first gallery</span>
|
||||
<span>Create Gallery</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function GalleryEditorPage() {
|
||||
<h1 className="studio-display">
|
||||
{isNew ? (
|
||||
<>
|
||||
A new <em>story.</em>
|
||||
New <em>gallery.</em>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -306,9 +306,9 @@ export default function GalleryEditorPage() {
|
||||
<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>
|
||||
<p className="studio-kicker">01 / Gallery_Metadata</p>
|
||||
<h2>Gallery_Info</h2>
|
||||
<p>Title and client-facing description.</p>
|
||||
</div>
|
||||
<div className="editor-fields editor-fields--two">
|
||||
<label>
|
||||
@@ -331,11 +331,11 @@ export default function GalleryEditorPage() {
|
||||
</label>
|
||||
</div>
|
||||
<label className="editor-fields__full">
|
||||
A note about this gallery
|
||||
Description:
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => setField('description', event.target.value)}
|
||||
placeholder="A few words to set the scene..."
|
||||
placeholder="Optional gallery description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
@@ -343,8 +343,8 @@ export default function GalleryEditorPage() {
|
||||
|
||||
<section className="editor-section">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">02 / The work</p>
|
||||
<h2>Bring it all in.</h2>
|
||||
<p className="studio-kicker">02 / Media_Buffer</p>
|
||||
<h2>Upload_Files</h2>
|
||||
<p>
|
||||
Originals stay private in object storage. Previews are prepared in the background.
|
||||
</p>
|
||||
@@ -382,9 +382,7 @@ export default function GalleryEditorPage() {
|
||||
</div>
|
||||
)}
|
||||
{gallery && gallery.media.length === 0 && (
|
||||
<div className="editor-media-empty">
|
||||
Your uploaded photographs will take center stage here.
|
||||
</div>
|
||||
<div className="editor-media-empty">No media uploaded.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -392,8 +390,8 @@ export default function GalleryEditorPage() {
|
||||
<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>
|
||||
<p className="studio-kicker">03 / Client_Control</p>
|
||||
<h2>Access_Settings</h2>
|
||||
</div>
|
||||
<div className="toggle-list">
|
||||
<Toggle
|
||||
@@ -451,8 +449,8 @@ export default function GalleryEditorPage() {
|
||||
|
||||
<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>
|
||||
<p className="studio-kicker">04 / Gallery_Config</p>
|
||||
<h2>Appearance_Module</h2>
|
||||
</div>
|
||||
<div className="choice-label">Mode</div>
|
||||
<div className="choice-row">
|
||||
@@ -512,15 +510,15 @@ export default function GalleryEditorPage() {
|
||||
|
||||
<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>
|
||||
<p className="studio-kicker">05 / Branding_Config</p>
|
||||
<h2>Studio_Identity</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'}
|
||||
placeholder={user?.name || 'Studio name'}
|
||||
/>
|
||||
</label>
|
||||
<label className="editor-field-single">
|
||||
@@ -528,7 +526,7 @@ export default function GalleryEditorPage() {
|
||||
<input
|
||||
value={draft.tagline}
|
||||
onChange={(event) => setField('tagline', event.target.value)}
|
||||
placeholder="Photographs for keeps."
|
||||
placeholder="Studio tagline"
|
||||
/>
|
||||
</label>
|
||||
<label className="editor-field-single">
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
|
||||
import { useAuth } from '../../features/auth/useAuth';
|
||||
import { changePassword, updateProfile } from '../../lib/api';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, setUser, signOut } = useAuth();
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [email, setEmail] = useState(user?.email || '');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [profileStatus, setProfileStatus] = useState('');
|
||||
const [passwordStatus, setPasswordStatus] = useState('');
|
||||
const [profileError, setProfileError] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setName(user.name);
|
||||
setEmail(user.email);
|
||||
}, [user]);
|
||||
|
||||
async function saveProfile(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSavingProfile(true);
|
||||
setProfileStatus('');
|
||||
setProfileError('');
|
||||
try {
|
||||
const updated = await updateProfile(name, email);
|
||||
setUser(updated);
|
||||
setProfileStatus('Profile saved');
|
||||
} catch (reason) {
|
||||
setProfileError(reason instanceof Error ? reason.message : 'Profile update failed.');
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPasswordStatus('');
|
||||
setPasswordError('');
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordStatus('Password updated');
|
||||
} catch (reason) {
|
||||
setPasswordError(reason instanceof Error ? reason.message : 'Password update failed.');
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="studio-page settings-page">
|
||||
<header className="studio-page__header settings-page__header">
|
||||
<div>
|
||||
<p className="studio-kicker">Workspace / Settings</p>
|
||||
<h1 className="studio-display">Settings</h1>
|
||||
<p className="studio-page__lede">Account profile, security, and session controls.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-layout">
|
||||
<section className="settings-panel">
|
||||
<div className="settings-panel__heading">
|
||||
<p className="studio-kicker">01 / Profile</p>
|
||||
<h2>Studio Profile</h2>
|
||||
<p>These details identify your photographer account.</p>
|
||||
</div>
|
||||
<form className="settings-form" onSubmit={saveProfile}>
|
||||
<label>
|
||||
Studio name:
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Email:
|
||||
<input
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{profileError && (
|
||||
<p className="settings-message settings-message--error">{profileError}</p>
|
||||
)}
|
||||
{profileStatus && (
|
||||
<p className="settings-message settings-message--success">{profileStatus}</p>
|
||||
)}
|
||||
<button className="settings-button" type="submit" disabled={savingProfile}>
|
||||
{savingProfile ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="settings-panel">
|
||||
<div className="settings-panel__heading">
|
||||
<p className="studio-kicker">02 / Security</p>
|
||||
<h2>Password Update</h2>
|
||||
<p>Change the password used to access this console.</p>
|
||||
</div>
|
||||
<form className="settings-form" onSubmit={savePassword}>
|
||||
<label>
|
||||
Current password:
|
||||
<input
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
New password:
|
||||
<input
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm new password:
|
||||
<input
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{passwordError && (
|
||||
<p className="settings-message settings-message--error">{passwordError}</p>
|
||||
)}
|
||||
{passwordStatus && (
|
||||
<p className="settings-message settings-message--success">{passwordStatus}</p>
|
||||
)}
|
||||
<button className="settings-button" type="submit" disabled={savingPassword}>
|
||||
{savingPassword ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="settings-panel settings-panel--session">
|
||||
<div className="settings-panel__heading">
|
||||
<p className="studio-kicker">03 / Session</p>
|
||||
<h2>Session Status</h2>
|
||||
</div>
|
||||
<div className="settings-session-row">
|
||||
<span>Status:</span>
|
||||
<strong>ACTIVE</strong>
|
||||
</div>
|
||||
<div className="settings-session-row">
|
||||
<span>Account ID:</span>
|
||||
<code>{user?.id}</code>
|
||||
</div>
|
||||
<div className="settings-session-actions">
|
||||
<Link to="/dashboard/dev">Dev Console</Link>
|
||||
<Link to="/dashboard/galleries">Galleries</Link>
|
||||
<button type="button" onClick={() => void signOut()}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
+971
-458
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
Reference in New Issue
Block a user