ai slop ah

This commit is contained in:
2026-08-22 17:27:55 +02:00
parent 6a5bb1d699
commit dc124d0d77
64 changed files with 7308 additions and 2448 deletions
+3 -5
View File
@@ -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>
}
/>
+4 -8
View File
@@ -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>&amp; 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">&#8599;</span>
ZIP ready <span aria-hidden="true">&#8599;</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>
);
}
-36
View File
@@ -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">&#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
@@ -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">&#8599;</span>
</motion.button>
)}
</div>
</div>
</Background>
);
}
-25
View File
@@ -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">
&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>
);
}
@@ -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">
&#9654;
</span>
<span>Your video goes here</span>
</div>
)}
{item.text && <p>{item.text}</p>}
</div>
);
}
+15 -26
View File
@@ -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;
-88
View File
@@ -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>
);
}
+7 -11
View File
@@ -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">&#8599;</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 -4
View File
@@ -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">&#8599;</span>
</Link>
+4 -4
View File
@@ -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>
+8 -12
View File
@@ -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">&#8599;</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>
+16 -20
View File
@@ -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">&#8599;</span>
View All <span aria-hidden="true">&#8599;</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">&#8599;</span>
Create Gallery <span aria-hidden="true">&#8599;</span>
</Link>
</div>
) : (
+4 -8
View File
@@ -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);
}}
>
+7 -13
View File
@@ -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">&#8599;</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>
);
}
File diff suppressed because it is too large Load Diff
-20
View File
@@ -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[];
}