fix: design updates padding and sht

This commit is contained in:
2026-08-24 02:18:48 +02:00
parent 832c5d57dd
commit 5ab60f4707
7 changed files with 189 additions and 94 deletions
@@ -23,27 +23,31 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="studio-shell"> <div className="studio-shell">
<aside className="studio-sidebar"> <aside className="studio-sidebar">
<Link className="studio-logo" to="/dashboard"> <div className="studio-sidebar__header">
<span className="studio-logo__mark">N</span> <Link className="studio-logo" to="/dashboard">
<span> <span className="studio-logo__mark">N</span>
Noah Bianchi <span className="studio-logo__text">
<small>photography studio</small> <span className="studio-logo__name">Noah Bianchi</span>
</span> <span className="studio-logo__sub">photography studio</span>
</Link> </span>
<div className="studio-sidebar__label">Workspace</div> </Link>
<nav className="studio-nav" aria-label="Main navigation"> </div>
{navItems.map((item) => ( <div className="studio-sidebar__nav-wrap">
<NavLink <div className="studio-sidebar__label">Workspace</div>
className={({ isActive }) => `studio-nav__item ${isActive ? 'is-active' : ''}`} <nav className="studio-nav" aria-label="Main navigation">
end={item.end} {navItems.map((item) => (
key={item.path} <NavLink
to={item.path} className={({ isActive }) => `studio-nav__item ${isActive ? 'is-active' : ''}`}
> end={item.end}
<span className="studio-nav__dot" aria-hidden="true" /> key={item.path}
{item.label} to={item.path}
</NavLink> >
))} <span className="studio-nav__dot" aria-hidden="true" />
</nav> {item.label}
</NavLink>
))}
</nav>
</div>
<div className="studio-sidebar__bottom"> <div className="studio-sidebar__bottom">
<div className="studio-account"> <div className="studio-account">
<span className="studio-account__avatar">{initials || 'N'}</span> <span className="studio-account__avatar">{initials || 'N'}</span>
@@ -66,7 +70,9 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
<div className="studio-mobilebar"> <div className="studio-mobilebar">
<Link className="studio-logo" to="/dashboard"> <Link className="studio-logo" to="/dashboard">
<span className="studio-logo__mark">N</span> <span className="studio-logo__mark">N</span>
<span>Noah Bianchi</span> <span className="studio-logo__text">
<span className="studio-logo__name">Noah Bianchi</span>
</span>
</Link> </Link>
<button <button
type="button" type="button"
@@ -1,19 +1,16 @@
interface StudioStatProps { interface StudioStatProps {
label: string; label: string;
value: string; value: string;
note: string;
accent?: 'coral' | 'violet' | 'gold' | 'ink'; accent?: 'coral' | 'violet' | 'gold' | 'ink';
} }
export function StudioStat({ label, value, note, accent = 'coral' }: StudioStatProps) { export function StudioStat({ label, value, accent = 'coral' }: StudioStatProps) {
return ( return (
<div className={`studio-stat studio-stat--${accent}`}> <div className={`studio-stat studio-stat--${accent}`}>
<div className="studio-stat__topline"> <div className="studio-stat__topline">
<span>{label}</span> <span>{label}</span>
<i aria-hidden="true" />
</div> </div>
<strong>{value}</strong> <strong>{value}</strong>
<small>{note}</small>
</div> </div>
); );
} }
@@ -1,13 +1,14 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { completeUpload, createUpload, uploadToStorage } from '../../lib/api'; import { getGallery, completeUpload, createUpload, uploadToStorage } from '../../lib/api';
import { formatBytes } from '../../lib/format'; import { formatBytes } from '../../lib/format';
import type { MediaItem } from '../../types/gallery'; import type { MediaItem } from '../../types/gallery';
type UploadStatus = 'queued' | 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled'; type UploadStatus = 'queued' | 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled';
interface UploadEntry { interface UploadEntry {
id: string; entryId: string;
mediaId: string;
file: File; file: File;
progress: number; progress: number;
status: UploadStatus; status: UploadStatus;
@@ -25,44 +26,50 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const controllers = useRef(new Map<string, AbortController>()); const controllers = useRef(new Map<string, AbortController>());
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const pollTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
useEffect(() => {
return () => {
pollTimers.current.forEach((t) => clearTimeout(t));
};
}, []);
function addFiles(files: File[]) { function addFiles(files: File[]) {
files.forEach((file, index) => { files.forEach((file, index) => {
const id = `${file.name}-${file.lastModified}-${Date.now()}-${index}`; const entryId = `${file.name}-${file.lastModified}-${Date.now()}-${index}`;
setEntries((current) => [...current, { id, file, progress: 0, status: 'queued' }]); setEntries((current) => [...current, { entryId, mediaId: '', file, progress: 0, status: 'queued' }]);
void upload(id, file); void upload(entryId, file);
}); });
} }
async function upload(id: string, file: File) { async function upload(entryId: string, file: File) {
const controller = new AbortController(); const controller = new AbortController();
controllers.current.set(id, controller); controllers.current.set(entryId, controller);
try { try {
setEntry(id, { status: 'uploading', progress: 0 }); setEntry(entryId, { status: 'uploading', progress: 0 });
const created = await createUpload(galleryId, file); const created = await createUpload(galleryId, file);
setEntry(entryId, { mediaId: created.media.id });
await uploadToStorage( await uploadToStorage(
created.uploadUrl, created.uploadUrl,
file, file,
(progress) => setEntry(id, { progress }), (progress) => setEntry(entryId, { progress }),
controller.signal, controller.signal,
); );
setEntry(id, { status: 'processing', progress: 100 }); setEntry(entryId, { status: 'processing', progress: 100 });
const completed = await completeUpload(created.uploadId); const completed = await completeUpload(created.uploadId);
setEntry(id, { const isReady = completed.processingStatus === 'READY';
status: completed.processingStatus === 'READY' ? 'ready' : 'processing', setEntry(entryId, { status: isReady ? 'ready' : 'processing', progress: 100 });
progress: 100,
});
onMedia(completed); onMedia(completed);
if (!isReady) {
pollMedia(entryId, completed.id);
}
window.setTimeout(onRefresh, 1400); window.setTimeout(onRefresh, 1400);
} catch (reason) { } catch (reason) {
if (reason instanceof DOMException && reason.name === 'AbortError') { if (reason instanceof DOMException && reason.name === 'AbortError') {
setEntry(id, { status: 'cancelled' }); setEntry(entryId, { status: 'cancelled' });
} else { } else {
const message = reason instanceof Error ? reason.message : 'Upload failed.'; const message = reason instanceof Error ? reason.message : 'Upload failed.';
setEntry(id, { setEntry(entryId, { status: 'failed', error: message });
status: 'failed',
error: message,
});
try { try {
window.localStorage.setItem( window.localStorage.setItem(
'studio:last-upload-error', 'studio:last-upload-error',
@@ -73,23 +80,43 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
} }
} }
} finally { } finally {
controllers.current.delete(id); controllers.current.delete(entryId);
} }
} }
function setEntry(id: string, update: Partial<UploadEntry>) { async function pollMedia(entryId: string, mediaId: string) {
const timer = window.setTimeout(async () => {
try {
const gallery = await getGallery(galleryId);
const media = gallery.media.find((m) => m.id === mediaId);
if (media?.processingStatus === 'READY' || media?.processingStatus === 'FAILED') {
setEntry(entryId, { status: 'ready' });
pollTimers.current.delete(entryId);
return;
}
pollMedia(entryId, mediaId);
} catch {
pollMedia(entryId, mediaId);
}
}, 3000);
pollTimers.current.set(entryId, timer);
}
function setEntry(entryId: string, update: Partial<UploadEntry>) {
setEntries((current) => setEntries((current) =>
current.map((entry) => (entry.id === id ? { ...entry, ...update } : entry)), current.map((entry) => (entry.entryId === entryId ? { ...entry, ...update } : entry)),
); );
} }
function cancel(id: string) { function cancel(entryId: string) {
controllers.current.get(id)?.abort(); const timer = pollTimers.current.get(entryId);
if (timer) { clearTimeout(timer); pollTimers.current.delete(entryId); }
controllers.current.get(entryId)?.abort();
} }
function retry(entry: UploadEntry) { function retry(entry: UploadEntry) {
setEntry(entry.id, { status: 'queued', progress: 0, error: undefined }); setEntry(entry.entryId, { status: 'queued', progress: 0, error: undefined });
void upload(entry.id, entry.file); void upload(entry.entryId, entry.file);
} }
const totalProgress = entries.length const totalProgress = entries.length
@@ -144,7 +171,7 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
</div> </div>
<div className="upload-queue__items"> <div className="upload-queue__items">
{entries.map((entry) => ( {entries.map((entry) => (
<div className="upload-row" key={entry.id}> <div className="upload-row" key={entry.entryId}>
<span className="upload-row__type"> <span className="upload-row__type">
{entry.file.type.startsWith('video/') ? 'MOV' : 'IMG'} {entry.file.type.startsWith('video/') ? 'MOV' : 'IMG'}
</span> </span>
@@ -159,7 +186,7 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
{(entry.status === 'uploading' || entry.status === 'processing') && ( {(entry.status === 'uploading' || entry.status === 'processing') && (
<button <button
type="button" type="button"
onClick={() => cancel(entry.id)} onClick={() => cancel(entry.entryId)}
aria-label={`Cancel ${entry.file.name}`} aria-label={`Cancel ${entry.file.name}`}
> >
&#215; &#215;
@@ -126,7 +126,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
) : ( ) : (
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span> <span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
)} )}
<span>{branding.studioName || 'Your studio'}</span> <span>{branding.studioName || 'Noah Bianchi'}</span>
</a> </a>
<div className="client-gallery__nav-actions"> <div className="client-gallery__nav-actions">
{gallery.downloadAllEnabled !== false && gallery.downloadsEnabled !== false && ( {gallery.downloadAllEnabled !== false && gallery.downloadsEnabled !== false && (
@@ -136,7 +136,6 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
: downloadJob?.status === 'READY' : downloadJob?.status === 'READY'
? 'ZIP ready' ? 'ZIP ready'
: 'Download gallery'}{' '} : 'Download gallery'}{' '}
<span aria-hidden="true">&#8595;</span>
</button> </button>
)} )}
<span className="client-gallery__edition">{gallery.clientName}</span> <span className="client-gallery__edition">{gallery.clientName}</span>
@@ -207,12 +206,6 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
<section className="client-download-section"> <section className="client-download-section">
<div className="client-download-section__copy"> <div className="client-download-section__copy">
<p className="client-kicker">Downloads</p> <p className="client-kicker">Downloads</p>
<h2 className="client-display">
Download
<br />
<em>originals.</em>
</h2>
<p>Download the original files from this gallery.</p>
</div> </div>
{gallery.downloadsEnabled !== false && ( {gallery.downloadsEnabled !== false && (
<button <button
@@ -253,8 +246,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
<div> <div>
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span> <span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
<div> <div>
<strong>{branding.studioName || 'Your studio'}</strong> <strong>{branding.studioName || 'Noah Bianchi'}</strong>
<small>{branding.tagline || 'Client Delivery'}</small>
</div> </div>
</div> </div>
<div className="client-footer__links"> <div className="client-footer__links">
@@ -56,25 +56,21 @@ export default function DashboardPage() {
<StudioStat <StudioStat
label="Total Galleries" label="Total Galleries"
value={String(galleries.length).padStart(2, '0')} value={String(galleries.length).padStart(2, '0')}
note="Gallery Buffer"
accent="coral" accent="coral"
/> />
<StudioStat <StudioStat
label="Published" label="Published"
value={String(published).padStart(2, '0')} value={String(published).padStart(2, '0')}
note="Live Deliveries"
accent="violet" accent="violet"
/> />
<StudioStat <StudioStat
label="Photographs" label="Photographs"
value={String(photos).padStart(2, '0')} value={String(photos).padStart(2, '0')}
note="Media Records"
accent="gold" accent="gold"
/> />
<StudioStat <StudioStat
label="Storage Used" label="Storage Used"
value={formatBytes(storage)} value={formatBytes(storage)}
note="Object Storage"
accent="ink" accent="ink"
/> />
</section> </section>
@@ -135,6 +135,9 @@ export default function GalleryEditorPage() {
...current, ...current,
coverMediaId: value.coverMediaId || current.coverMediaId, coverMediaId: value.coverMediaId || current.coverMediaId,
})); }));
if (value.media.some((m) => m.processingStatus === 'PROCESSING')) {
setTimeout(() => void refreshGallery(), 3000);
}
} catch { } catch {
// A completed upload can take a moment to appear while processing. // A completed upload can take a moment to appear while processing.
} }
@@ -602,7 +605,7 @@ export default function GalleryEditorPage() {
type="url" type="url"
value={draft.websiteUrl} value={draft.websiteUrl}
onChange={(event) => setField('websiteUrl', event.target.value)} onChange={(event) => setField('websiteUrl', event.target.value)}
placeholder="https://yourstudio.com" placeholder="https://noahbianchi.be"
/> />
</label> </label>
<label className="editor-field-single"> <label className="editor-field-single">
@@ -610,7 +613,7 @@ export default function GalleryEditorPage() {
<input <input
value={draft.instagramUrl} value={draft.instagramUrl}
onChange={(event) => setField('instagramUrl', event.target.value)} onChange={(event) => setField('instagramUrl', event.target.value)}
placeholder="https://instagram.com/yourstudio" placeholder="https://instagram.com/noahbianchi"
/> />
</label> </label>
</section> </section>
@@ -708,13 +711,16 @@ function EditorMediaTile({
<div className="editor-media-tile__image"> <div className="editor-media-tile__image">
{isVideo ? ( {isVideo ? (
<button <button
className="editor-media-tile__video-placeholder" className={`editor-media-tile__video-placeholder ${item.thumbnailUrl ? 'has-thumb' : ''}`}
type="button" type="button"
onClick={(e) => { e.stopPropagation(); onOpenVideo?.(); }} onClick={(e) => { e.stopPropagation(); onOpenVideo?.(); }}
aria-label="Play video" aria-label="Play video"
> >
{item.thumbnailUrl ? (
<img src={item.thumbnailUrl} alt="" className="editor-media-tile__video-thumb" />
) : null}
<span className="editor-media-tile__play-icon">&#9654;</span> <span className="editor-media-tile__play-icon">&#9654;</span>
<span>VIDEO</span> <span className="editor-media-tile__video-label-text">VIDEO</span>
</button> </button>
) : item.previewUrl ? ( ) : item.previewUrl ? (
<img src={item.previewUrl} alt="" loading="lazy" /> <img src={item.previewUrl} alt="" loading="lazy" />
@@ -724,7 +730,7 @@ function EditorMediaTile({
</div> </div>
)} )}
{isVideo && ( {isVideo && (
<span className="editor-media-tile__video-label"> <span className="editor-media-tile__film-label">
Film {formatDuration(item.durationSeconds)} Film {formatDuration(item.durationSeconds)}
</span> </span>
)} )}
+90 -19
View File
@@ -961,9 +961,9 @@ a {
.studio-sidebar { .studio-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 220px; width: 240px;
min-width: 220px; min-width: 240px;
padding: 28px 20px; padding: 0;
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border-subtle); border-right: 1px solid var(--border-subtle);
overflow-y: auto; overflow-y: auto;
@@ -971,44 +971,73 @@ a {
z-index: 100; z-index: 100;
} }
.studio-sidebar__header {
display: flex;
flex-direction: column;
padding: 28px 24px 20px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 24px;
gap: 6px;
}
.studio-sidebar__label { .studio-sidebar__label {
font-size: 9px; font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--fg-muted); color: var(--fg-muted);
margin-bottom: 14px; padding: 0 24px;
margin-bottom: 8px;
} }
.studio-logo { .studio-logo {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
margin-bottom: 36px;
} }
.studio-logo__mark { .studio-logo__mark {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 34px;
height: 32px; height: 34px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
background: var(--accent); background: var(--accent);
color: var(--bg); color: var(--bg);
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
} }
.studio-logo__text {
display: flex;
flex-direction: column;
gap: 1px;
}
.studio-logo__name {
font-size: 13px;
font-weight: 600;
color: var(--fg);
line-height: 1.2;
}
.studio-logo__sub {
font-size: 9px;
color: var(--fg-muted);
letter-spacing: 0.04em;
}
.studio-nav { .studio-nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
margin-bottom: 24px; padding: 8px 12px;
margin-bottom: 0;
} }
.studio-nav__item { .studio-nav__item {
@@ -1040,23 +1069,29 @@ a {
height: 4px; height: 4px;
border-radius: 50%; border-radius: 50%;
background: var(--fg-muted); background: var(--fg-muted);
flex-shrink: 0;
} }
.studio-nav__item.is-active .studio-nav__dot { .studio-nav__item.is-active .studio-nav__dot {
background: var(--accent); background: var(--accent);
} }
.studio-sidebar__nav-wrap {
flex: 1;
padding: 0 12px;
}
.studio-sidebar__note { .studio-sidebar__note {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
padding: 12px; padding: 12px;
margin: 0 24px 16px;
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 10px; font-size: 10px;
color: var(--fg-muted); color: var(--fg-muted);
line-height: 1.5; line-height: 1.5;
margin-bottom: 24px;
} }
.studio-sidebar__note strong { .studio-sidebar__note strong {
@@ -2121,13 +2156,14 @@ input[type='color'] {
} }
.editor-media-tile__video-placeholder { .editor-media-tile__video-placeholder {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
gap: 8px; gap: 4px;
color: var(--fg-muted); color: var(--fg-muted);
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
@@ -2137,19 +2173,53 @@ input[type='color'] {
border: 0; border: 0;
cursor: pointer; cursor: pointer;
transition: background var(--transition); transition: background var(--transition);
overflow: hidden;
}
.editor-media-tile__video-placeholder.has-thumb {
background: transparent;
} }
.editor-media-tile__video-placeholder:hover { .editor-media-tile__video-placeholder:hover {
background: var(--surface-hover); background: var(--surface-hover);
} }
.editor-media-tile__play-icon { .editor-media-tile__video-placeholder.has-thumb:hover {
font-size: 24px; background: rgba(0, 0, 0, 0.3);
color: var(--fg-secondary);
line-height: 1;
} }
.editor-media-tile__video-label { .editor-media-tile__video-thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.editor-media-tile__play-icon {
position: relative;
z-index: 1;
font-size: 28px;
color: rgba(243, 237, 232, 0.9);
line-height: 1;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
transition: transform var(--transition);
}
.editor-media-tile__video-placeholder:hover .editor-media-tile__play-icon {
transform: scale(1.1);
}
.editor-media-tile__video-label-text {
position: relative;
z-index: 1;
font-size: 8px;
color: rgba(243, 237, 232, 0.8);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
}
.editor-media-tile__film-label {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
left: 8px; left: 8px;
@@ -2160,6 +2230,7 @@ input[type='color'] {
text-transform: uppercase; text-transform: uppercase;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
color: var(--fg); color: var(--fg);
z-index: 1;
} }
.editor-media-tile__placeholder { .editor-media-tile__placeholder {