// 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 => ( ))}
{filter === 'collection' && ( )}
{[{v:'1d',l:'24h'},{v:'7d',l:'7 days'},{v:'30d',l:'30 days'},{v:'never',l:'Never'}].map(o => ( ))}
{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 => ( ))}
{['small','medium','large'].map(s => ( ))}
{/* Preview */}
Preview
{text || '© STUDIO'}
{saving ? 'Saving…' : 'Save watermark'} Cancel
); } function Toggle({ checked, onChange, label }) { return ( ); } function Field({ label, children }) { return (
{label}
{children}
); } 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 (
New collection
}/>
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 = (
Studio
Dashboard
); const footer = (
Studio online
); const tabBar = (
{['login','register'].map(t => ( ))}
); const wrap = (child) => (
{logo}{child}{footer}
); // ── 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.
); // ── Login form ──────────────────────────────────────────────────────────── if (tab === 'login') return wrap( <> {tabBar}
Welcome back.
Sign in to your studio.
{ setUser(e.target.value); setErr(''); }} placeholder="your-username" style={inputS} autoCapitalize="none" autoCorrect="off"/> { setPw(e.target.value); setErr(''); }} placeholder="••••••••" style={inputS}/> {err && (
{errType === 'expired' ? '⏳' : errType === 'suspended' ? '🔒' : '⚠️'}
{errType === 'expired' ? 'Account Expired' : errType === 'suspended' ? 'Account Suspended' : 'Sign In Failed'}
{err}
)}
); // ── Register form ───────────────────────────────────────────────────────── return wrap( <> {tabBar}
Join the studio.
Your request will be reviewed by the admin.
{ setName(e.target.value); setErr(''); }} placeholder="Your full name" style={inputS}/> { setEmail(e.target.value); setErr(''); }} placeholder="you@example.com" style={inputS} autoCapitalize="none" autoCorrect="off"/> { setUser(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')); setErr(''); }} placeholder="choose-a-username" style={inputS} autoCapitalize="none" autoCorrect="off"/> { setPw(e.target.value); setErr(''); }} placeholder="••••••••" style={inputS}/> { setPw2(e.target.value); setErr(''); }} placeholder="••••••••" style={inputS}/> {err &&
{err}
}
); } // ── 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) => (
{s.icon}
{s.title}
{s.desc}
))}
); } // ── 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 ? : 📷}
{err &&
{err}
}
); } // ── 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'} } 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 (
Studio Branding
{/* Logo upload */}
Studio Logo
{logoPreview ? logo : No logo}
{(hasLogo || logoPreview) && ( )}
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 */}
Tagline
setTagline(e.target.value)} placeholder="A short line about your studio" style={{ ...inputS, width: '100%' }} maxLength={120} />
{/* Preview */} {(logoPreview || studioName || tagline) && (
Preview on client gallery
{logoPreview && ( logo )}
{studioName &&
{studioName}
} {tagline &&
{tagline}
}
)} {err &&
{err}
}
); } // ── 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 (
Studio
New event
}/>
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}

`); 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', }}>
Share QR Code
{label}
}/>
{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 });