fix: design updates padding and sht
This commit is contained in:
@@ -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}`}
|
||||
>
|
||||
×
|
||||
@@ -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">↓</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">▶</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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user