// Modals: Share, Watermark, Login, NewCollection
function Modal({ children, onClose, width = 520 }) {
return (
e.stopPropagation()} style={{
width: '100%', maxWidth: width, maxHeight: '88vh', overflowY: 'auto',
background: 'var(--bg-1)', border: '1px solid var(--line-2)',
borderRadius: 10, padding: 22,
boxShadow: '0 32px 80px rgba(0,0,0,0.8)',
animation: 'fade-up 180ms ease',
}}>{children}
);
}
function ShareModal({ onClose, shares, collections, onCreate, onRevoke }) {
const [label, setLabel] = React.useState('Client Preview');
const [filter, setFilter] = React.useState('picks');
const [collection, setCollection] = React.useState('');
const [expiry, setExpiry] = React.useState('7d');
const [watermark, setWatermark] = React.useState(true);
const [download, setDownload] = React.useState(true);
const [pinProtect, setPinProtect] = React.useState(false);
const [created, setCreated] = React.useState(null);
const [creating, setCreating] = React.useState(false);
const [qrTarget, setQrTarget] = React.useState(null); // { url, label }
const handleCreate = async () => {
setCreating(true);
const share = await onCreate({ label, filter: filter === 'collection' ? `collection:${collection}` : filter, expires: expiry, watermark, download, pinProtect });
setCreating(false);
if (share) setCreated(share);
};
const copyUrl = (token) => {
const url = `${window.location.origin}/share/${token}`;
navigator.clipboard.writeText(url).catch(() => {});
};
return (
Client Delivery
Share gallery
}/>
{!created ? (
<>
setLabel(e.target.value)} style={inputS}/>
{[
{ v: 'all', l: 'All photos' }, { v: 'picks', l: 'Picks only' },
{ v: 'rated3', l: 'Rated 3★+' }, { v: 'collection', l: 'A collection…' },
].map(o => (
setFilter(o.v)} style={radioBtnS(filter === o.v)}>
{filter === o.v && }
{o.l}
))}
{filter === 'collection' && (
setCollection(e.target.value)} style={inputS}>
— select —
{collections.map(c => {c.name} ({c.count || (c.files && c.files.length) || 0}) )}
)}
{[{v:'1d',l:'24h'},{v:'7d',l:'7 days'},{v:'30d',l:'30 days'},{v:'never',l:'Never'}].map(o => (
setExpiry(o.v)} style={segS(expiry === o.v)}>{o.l}
))}
{creating ? 'Creating…' : 'Create share link'}
Cancel
>
) : (
<>
Share URL
{window.location.origin}/share/{created.token}
} onClick={() => copyUrl(created.token)}>Copy
} onClick={() => setQrTarget({ token: created.token, url: `${window.location.origin}/share/${created.token}`, label: created.label || 'Share' })}>QR
Filter: {created.filter}
Expires: {created.expires ? new Date(created.expires).toLocaleDateString() : 'never'}
setCreated(null)} style={{ width: '100%' }}>Create another
>
)}
{shares.length > 0 && (
Active links · {shares.length}
{shares.map(s => (
setQrTarget({ token: s.token, url: `${window.location.origin}/share/${s.token}`, label: s.label || 'Share' })}/>
))}
)}
{qrTarget && setQrTarget(null)}/>}
);
}
function relDate(ts) {
const diff = ts - Date.now();
const d = Math.round(diff / 86400000);
if (d < 0) return `${-d}d ago`;
if (d < 1) return 'today';
return `${d}d`;
}
function WatermarkModal({ onClose, config, onSave }) {
const [text, setText] = React.useState(config.text || '© STUDIO 2026');
const [opacity, setOpacity] = React.useState(config.opacity || 65);
const [position, setPosition] = React.useState(config.position || 'bottom-right');
const [size, setSize] = React.useState(config.size || 'medium');
const [saving, setSaving] = React.useState(false);
const handleSave = async () => {
setSaving(true);
await onSave({ text, opacity, position, size });
setSaving(false);
onClose();
};
return (
Delivery protection
Watermark
}/>
setText(e.target.value)} placeholder="e.g. © Studio 2026" style={inputS}/>
setOpacity(+e.target.value)} style={{ width: '100%', accentColor: 'var(--accent)' }}/>
{['top-left','top-center','top-right','center-left','center','center-right','bottom-left','bottom-center','bottom-right'].map(p => (
setPosition(p)} style={{
height: 28, background: position === p ? 'var(--accent-soft)' : 'var(--bg-2)',
border: `1px solid ${position === p ? 'var(--accent)' : 'var(--line-1)'}`,
borderRadius: 4, cursor: 'pointer', position: 'relative',
}}>
))}
{['small','medium','large'].map(s => (
setSize(s)} style={segS(size === s)}>{s}
))}
{/* Preview */}
{saving ? 'Saving…' : 'Save watermark'}
Cancel
);
}
function Toggle({ checked, onChange, label }) {
return (
onChange(!checked)} style={{
width: 28, height: 16, borderRadius: 99,
background: checked ? 'var(--accent)' : 'var(--bg-3)',
border: `1px solid ${checked ? 'var(--accent)' : 'var(--line-2)'}`,
position: 'relative', cursor: 'pointer', padding: 0, transition: 'background 160ms',
}}>
{label}
);
}
function Field({ label, children }) {
return (
);
}
const inputS = {
width: '100%', background: 'var(--bg-2)', border: '1px solid var(--line-2)',
borderRadius: 6, padding: '8px 10px', fontSize: 12.5, color: 'var(--ink-0)',
outline: 'none', fontFamily: 'inherit',
};
const radioBtnS = (active) => ({
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
background: active ? 'var(--accent-soft)' : 'var(--bg-2)',
border: `1px solid ${active ? 'var(--accent-dim)' : 'var(--line-1)'}`,
borderRadius: 6, cursor: 'pointer', color: 'var(--ink-1)', fontSize: 12,
textAlign: 'left',
});
const segS = (active) => ({
flex: 1, padding: '6px 8px', fontSize: 11.5,
background: active ? 'var(--bg-4)' : 'var(--bg-2)',
color: active ? 'var(--ink-0)' : 'var(--ink-2)',
border: `1px solid ${active ? 'var(--line-3)' : 'var(--line-1)'}`,
borderRadius: 4, cursor: 'pointer', textTransform: 'capitalize',
});
function NewCollectionModal({ onClose, onCreate }) {
const [name, setName] = React.useState('');
const [creating, setCreating] = React.useState(false);
const handle = async () => {
if (!name.trim()) return;
setCreating(true);
await onCreate(name.trim());
setCreating(false);
onClose();
};
return (
setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handle(); }}
placeholder="e.g. Retouch Queue" style={inputS}/>
{creating ? 'Creating…' : 'Create'}
Cancel
);
}
function LoginScreen({ onLogin, theme, setTheme }) {
const [tab, setTab] = React.useState('login'); // 'login' | 'register'
const [user, setUser] = React.useState('');
const [pw, setPw] = React.useState('');
const [pw2, setPw2] = React.useState('');
const [name, setName] = React.useState('');
const [email, setEmail] = React.useState('');
const [err, setErr] = React.useState('');
const [errType, setErrType] = React.useState('error'); // 'error' | 'expired' | 'suspended'
const [loading, setLoading] = React.useState(false);
const [pending, setPending] = React.useState(false); // registration submitted
const switchTab = (t) => { setTab(t); setErr(''); setErrType('error'); setUser(''); setPw(''); setPw2(''); setName(''); setEmail(''); };
const submitLogin = async (e) => {
e.preventDefault();
if (!user.trim() || !pw.length) { setErr('Please fill in all fields.'); setErrType('error'); return; }
setLoading(true);
const result = await onLogin(user.trim(), pw);
setLoading(false);
if (!result.ok) {
const msg = result.message || 'Incorrect username or password.';
setErr(msg);
if (msg.toLowerCase().includes('expired')) setErrType('expired');
else if (msg.toLowerCase().includes('suspended')) setErrType('suspended');
else setErrType('error');
}
};
const submitRegister = async (e) => {
e.preventDefault();
if (!user.trim() || !name.trim() || !email.trim() || !pw.length) { setErr('Please fill in all fields.'); return; }
if (!/\S+@\S+\.\S+/.test(email.trim())) { setErr('Please enter a valid email address.'); return; }
if (pw !== pw2) { setErr('Passwords do not match.'); return; }
if (pw.length < 6) { setErr('Password must be at least 6 characters.'); return; }
if (!/^[a-z0-9_-]+$/.test(user.trim())) { setErr('Username: only letters, numbers, hyphens, underscores.'); return; }
setLoading(true);
try {
const r = await fetch('/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.trim(), password: pw, display_name: name.trim(), email: email.trim() }),
});
const data = await r.json().catch(() => ({}));
if (r.ok) {
setPending(true);
} else {
setErr(data.detail || 'Registration failed. Please try again.');
}
} catch {
setErr('Network error. Please try again.');
}
setLoading(false);
};
const cardStyle = {
width: 400, padding: '40px 36px',
background: 'var(--bg-1)', border: '1px solid var(--line-2)',
borderRadius: 12, boxShadow: '0 24px 80px rgba(0,0,0,0.5)',
};
const logo = (
);
const footer = (
Studio online
setTheme(theme === 'graphite' ? 'darkroom' : 'graphite')}
style={{ background: 'none', border: 'none', color: 'var(--ink-3)', fontFamily: 'var(--mono)', fontSize: 9.5, textTransform: 'uppercase', letterSpacing: 0.14, cursor: 'pointer' }}>
{theme === 'graphite' ? 'Graphite' : 'Darkroom'}
);
const tabBar = (
{['login','register'].map(t => (
switchTab(t)} style={{
flex: 1, height: 30, borderRadius: 5, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 12, fontWeight: 500,
background: tab === t ? 'var(--bg-2)' : 'transparent',
color: tab === t ? 'var(--ink-0)' : 'var(--ink-3)',
transition: 'all 0.15s',
}}>
{t === 'login' ? 'Sign In' : 'Request Access'}
))}
);
const wrap = (child) => (
);
// ── Pending approval screen ───────────────────────────────────────────────
if (pending) return wrap(
<>
⏳
Request sent!
Your account is awaiting admin approval. You'll receive an email at {email} once it's approved.
{ setPending(false); switchTab('login'); }} style={{
width: '100%', height: 36, borderRadius: 6, border: '1px solid var(--line-1)',
background: 'transparent', color: 'var(--ink-1)', fontFamily: 'inherit', fontSize: 13, cursor: 'pointer',
}}>Back to sign in
>
);
// ── Login form ────────────────────────────────────────────────────────────
if (tab === 'login') return wrap(
<>
{tabBar}
Welcome back.
Sign in to your studio.
>
);
// ── Register form ─────────────────────────────────────────────────────────
return wrap(
<>
{tabBar}
Join the studio.
Your request will be reviewed by the admin.
>
);
}
// ── Welcome / onboarding modal ────────────────────────────────────────────────
function WelcomeModal({ displayName, onClose }) {
const steps = [
{ icon: '📷', title: 'Upload via FTP', desc: 'Connect your camera using the FTP config from Settings → FTP Config.' },
{ icon: '⭐', title: 'Rate & cull', desc: 'Use 1–5 keys to rate, P to pick, X to reject. Arrow keys navigate.' },
{ icon: '🖼️', title: 'Set your profile photo', desc: 'Click your initials in the sidebar footer to upload a profile photo.' },
{ icon: '🔗', title: 'Share with clients', desc: 'Use the Share button to generate a client gallery link.' },
{ icon: '💧', title: 'Add a watermark', desc: 'Go to Watermark in the sidebar to protect your proofs.' },
];
return (
Welcome, {displayName}! 👋
Here's how to get started with your studio.
{steps.map((s, i) => (
))}
Start shooting 🚀
);
}
// ── Profile photo modal ───────────────────────────────────────────────────────
function ProfilePhotoModal({ onClose, onSaved }) {
const [preview, setPreview] = React.useState(null);
const [file, setFile] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState('');
const pick = (e) => {
const f = e.target.files[0];
if (!f) return;
if (!f.type.startsWith('image/')) { setErr('Please select an image file.'); return; }
setFile(f);
setErr('');
const reader = new FileReader();
reader.onload = ev => setPreview(ev.target.result);
reader.readAsDataURL(f);
};
const save = async () => {
if (!file) return;
setSaving(true);
const fd = new FormData();
fd.append('photo', file);
try {
const r = await fetch('/api/profile/photo', { method: 'POST', body: fd });
if (r.ok) { onSaved && onSaved(); onClose(); }
else { const d = await r.json().catch(() => ({})); setErr(d.detail || 'Upload failed.'); }
} catch { setErr('Network error.'); }
setSaving(false);
};
return (
Profile Photo
{preview
?
:
📷 }
Choose photo
{err &&
{err}
}
Cancel
{saving ? 'Saving…' : 'Save'}
);
}
// ── Share link row with inline client review ──────────────────────────────────
function ShareLinkRow({ share: s, onRevoke, copyUrl, onQR }) {
const [open, setOpen] = React.useState(false);
const [review, setReview] = React.useState(null); // {approvals, liked}
const [loading, setLoading] = React.useState(false);
const loadReview = async () => {
if (review) { setOpen(o => !o); return; }
setLoading(true);
try {
const r = await fetch(`/api/share/${s.token}/approvals`);
if (r.ok) setReview(await r.json());
} catch {}
setLoading(false);
setOpen(true);
};
const approvals = review ? review.approvals || {} : {};
const liked = review ? review.liked || [] : [];
const approved = Object.entries(approvals).filter(([,v]) => v.status === 'approved');
const changes = Object.entries(approvals).filter(([,v]) => v.status === 'changes');
const totalReviewed = approved.length + changes.length;
// Summary pill colour
const hasFeedback = totalReviewed > 0 || liked.length > 0;
return (
{/* Main row */}
{s.label}
/share/{s.token} · {s.filter}
{/* Quick review summary */}
{hasFeedback && review && (
{liked.length > 0 && (
★ {liked.length}
)}
{approved.length > 0 && (
✓ {approved.length}
)}
{changes.length > 0 && (
✗ {changes.length}
)}
)}
{s.expires ? `expires ${relDate(s.expires)}` : 'no expiry'}
{loading ? '…' : open ? 'Hide review' : 'Client review'}
} onClick={() => copyUrl(s.token)}/>
}/>
onRevoke(s.token)} icon={}/>
{/* Review panel */}
{open && review && (
{totalReviewed === 0 && liked.length === 0 ? (
Client hasn't reviewed any photos yet.
) : (
<>
{/* Changes requested */}
{changes.length > 0 && (
0 || approved.length > 0 ? '1px solid var(--line-1)' : 'none' }}>
Changes requested · {changes.length}
{changes.map(([fname, data]) => (
{ e.target.style.display='none'; }}
style={{ width: 42, height: 42, objectFit: 'cover', borderRadius: 4, flexShrink: 0, background: 'var(--bg-2)' }}
alt=""/>
{fname}
{data.note ? (
"{data.note}"
) : (
No note left
)}
✗ Changes
))}
)}
{/* Approved */}
{approved.length > 0 && (
0 ? '1px solid var(--line-1)' : 'none' }}>
Approved · {approved.length}
{approved.map(([fname]) => (
✓ {fname.length > 22 ? fname.slice(0,10)+'…'+fname.slice(-8) : fname}
))}
)}
{/* Favorites */}
{liked.length > 0 && (
Favorited · {liked.length}
{liked.map(fname => (
★ {fname.length > 22 ? fname.slice(0,10)+'…'+fname.slice(-8) : fname}
))}
)}
>
)}
)}
);
}
// ── Branding modal ────────────────────────────────────────────────────────────
function BrandingModal({ onClose, onSaved }) {
const [studioName, setStudioName] = React.useState('');
const [tagline, setTagline] = React.useState('');
const [logoFile, setLogoFile] = React.useState(null);
const [logoPreview, setLogoPreview] = React.useState(null);
const [hasLogo, setHasLogo] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState('');
React.useEffect(() => {
fetch('/api/branding')
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d) {
setStudioName(d.studio_name || '');
setTagline(d.tagline || '');
setHasLogo(!!d.has_logo);
if (d.has_logo) setLogoPreview(`/api/branding/logo?t=${Date.now()}`);
}
})
.catch(() => {});
}, []);
const pickLogo = (e) => {
const f = e.target.files[0];
if (!f) return;
if (f.size > 2 * 1024 * 1024) { setErr('Logo must be under 2 MB'); return; }
setLogoFile(f);
setErr('');
const reader = new FileReader();
reader.onload = ev => setLogoPreview(ev.target.result);
reader.readAsDataURL(f);
};
const removeLogo = async () => {
await fetch('/api/branding/logo', { method: 'DELETE' });
setLogoFile(null);
setLogoPreview(null);
setHasLogo(false);
};
const save = async () => {
setSaving(true); setErr('');
try {
// Save text settings
const r = await fetch('/api/branding', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ studio_name: studioName, tagline }),
});
if (!r.ok) throw new Error('Failed to save branding');
// Upload logo if new one picked
if (logoFile) {
const fd = new FormData();
fd.append('logo', logoFile);
const r2 = await fetch('/api/branding/logo', { method: 'POST', body: fd });
if (!r2.ok) throw new Error('Failed to upload logo');
setHasLogo(true);
setLogoFile(null);
}
onSaved && onSaved();
onClose();
} catch (ex) {
setErr(ex.message || 'Something went wrong');
} finally {
setSaving(false);
}
};
const hdr = { fontSize: 10, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 0.12, marginBottom: 6 };
return (
{/* Logo upload */}
Studio Logo
{logoPreview
?
:
No logo }
{hasLogo || logoPreview ? 'Replace logo' : 'Upload logo'}
{(hasLogo || logoPreview) && (
Remove logo
)}
PNG or JPEG, max 2 MB. Transparent background looks best.
{/* Studio name */}
Studio Name
setStudioName(e.target.value)}
placeholder="Your Studio Name"
style={{ ...inputS, width: '100%' }}
maxLength={80}
/>
Shown in client gallery header when no logo is set
{/* Tagline */}
{/* Preview */}
{(logoPreview || studioName || tagline) && (
Preview on client gallery
{logoPreview && (
)}
{studioName &&
{studioName}
}
{tagline &&
{tagline}
}
)}
{err && {err}
}
Cancel
{saving ? 'Saving…' : 'Save branding'}
);
}
// ── Event Modal ───────────────────────────────────────────────────────────────
function EventModal({ onClose, onCreate }) {
const today = new Date().toISOString().slice(0, 10);
const [name, setName] = React.useState('');
const [date, setDate] = React.useState(today);
const [activate, setActivate] = React.useState(true);
const [creating, setCreating] = React.useState(false);
const handle = async () => {
if (!name.trim()) return;
setCreating(true);
await onCreate({ name: name.trim(), date, activate });
setCreating(false);
};
return (
setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handle(); }}
placeholder="e.g. Smith Wedding — Ceremony"
style={inputS}/>
setDate(e.target.value)} style={inputS}/>
{activate && (
Any previous live event will be paused. You can switch events at any time from the sidebar.
)}
{creating ? 'Creating…' : 'Create event'}
Cancel
);
}
// ── QR Code Modal ─────────────────────────────────────────────────────────────
function QRModal({ token, url, label, onClose }) {
const imgSrc = `/api/share/${token}/qr`;
const [loaded, setLoaded] = React.useState(false);
const [err, setErr] = React.useState(false);
const handlePrint = () => {
const win = window.open('', '_blank', 'width=420,height=560');
if (!win) return;
win.document.write(`QR — ${label}
${label} ${url}
Print
`);
win.document.close(); win.focus();
};
return (
e.stopPropagation()} style={{
background: 'var(--bg-1)', border: '1px solid var(--line-2)', borderRadius: 14,
padding: '28px 28px 22px', display: 'flex', flexDirection: 'column', alignItems: 'center',
boxShadow: '0 32px 80px rgba(0,0,0,0.7)', animation: 'fade-up 180ms ease',
}}>
{err ? (
⚠
QR generation failed.
Check server logs.
) : (
<>
{!loaded &&
Generating…
}
setLoaded(true)}
onError={() => setErr(true)}
alt="QR code"/>
>
)}
{url}
}>Print / Save
navigator.clipboard.writeText(url).catch(()=>{})}>Copy URL
);
}
Object.assign(window, { Modal, ShareModal, WatermarkModal, NewCollectionModal, LoginScreen, WelcomeModal, ProfilePhotoModal, BrandingModal, Toggle, Field, inputS, QRModal, EventModal });