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