update style
This commit is contained in:
@@ -6,6 +6,7 @@ import GalleryPreviewPage from './pages/GalleryPreviewPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import PublicGalleryPage from './pages/PublicGalleryPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import RootPage from './pages/RootPage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import GalleriesPage from './pages/dashboard/GalleriesPage';
|
||||
import GalleryEditorPage from './pages/dashboard/GalleryEditorPage';
|
||||
@@ -19,6 +20,7 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootPage />} />
|
||||
<Route path="/g/:slug" element={<PublicGalleryPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function AuthLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="auth-shell">
|
||||
<div className="auth-shell__texture" aria-hidden="true" />
|
||||
<div className="auth-shell__brand">
|
||||
<span className="platform-mark">N</span>
|
||||
<span>Noah Bianchi</span>
|
||||
</div>
|
||||
<div className="auth-shell__aside">
|
||||
<p className="platform-kicker">Private delivery system</p>
|
||||
<h1 className="platform-display">Studio Access</h1>
|
||||
<p>Photographer gallery console for client delivery.</p>
|
||||
<div className="auth-shell__content">
|
||||
<Link to="/" className="auth-shell__brand">
|
||||
<span className="platform-mark">N</span>
|
||||
<span>Noah Bianchi</span>
|
||||
</Link>
|
||||
<div className="auth-shell__aside">
|
||||
<p className="platform-kicker">Private delivery system</p>
|
||||
<h1 className="platform-display">Studio Access</h1>
|
||||
<p>Photographer gallery console for client delivery.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-shell__panel">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -45,13 +45,6 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
))}
|
||||
</nav>
|
||||
<div className="studio-sidebar__bottom">
|
||||
<div className="studio-sidebar__note">
|
||||
<span className="studio-sidebar__note-mark">+</span>
|
||||
<span>
|
||||
<strong>System Status</strong>
|
||||
<small>Delivery console active.</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="studio-account">
|
||||
<span className="studio-account__avatar">{initials || 'N'}</span>
|
||||
<span className="studio-account__details">
|
||||
|
||||
@@ -23,6 +23,10 @@ export function GalleryCard({ gallery, onDelete }: GalleryCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(clientURL, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="gallery-card">
|
||||
<Link className="gallery-card__cover" to={`/dashboard/galleries/${gallery.id}/edit`}>
|
||||
@@ -61,13 +65,15 @@ export function GalleryCard({ gallery, onDelete }: GalleryCardProps) {
|
||||
Preview
|
||||
</Link>
|
||||
{gallery.status === 'published' && (
|
||||
<button
|
||||
className="text-action text-action--muted"
|
||||
type="button"
|
||||
onClick={() => void copyLink()}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
<>
|
||||
<button className="gallery-card__link-button" type="button" onClick={() => void copyLink()}>
|
||||
<span>{copied ? 'Copied' : 'Copy link'}</span>
|
||||
</button>
|
||||
<button className="gallery-card__link-button gallery-card__link-button--open" type="button" onClick={openLink}>
|
||||
<span>Open</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="gallery-card__delete"
|
||||
|
||||
@@ -95,7 +95,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
}
|
||||
|
||||
function openPhoto(item: MediaItem) {
|
||||
const index = photos.findIndex((photo) => photo.id === item.id);
|
||||
const index = gallery.media.findIndex((photo) => photo.id === item.id);
|
||||
setViewerIndex(index >= 0 ? index : null);
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
<AnimatePresence>
|
||||
{viewerIndex !== null && (
|
||||
<PhotoViewer
|
||||
items={photos}
|
||||
items={gallery.media}
|
||||
index={viewerIndex}
|
||||
onClose={() => setViewerIndex(null)}
|
||||
onChange={setViewerIndex}
|
||||
@@ -308,15 +308,24 @@ function MediaCard({
|
||||
className={`client-media-card ${isVideo ? 'client-media-card--video' : ''} ${gallery.watermarkEnabled ? 'client-media-card--watermarked' : ''}`}
|
||||
>
|
||||
{isVideo ? (
|
||||
<div className="client-media-card__video-wrap">
|
||||
{ready && item.previewUrl ? (
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={gallery.cover?.previewUrl}
|
||||
src={item.previewUrl}
|
||||
/>
|
||||
<button
|
||||
className="client-media-card__video-wrap"
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={!ready}
|
||||
>
|
||||
{ready && (item.thumbnailUrl || item.previewUrl) ? (
|
||||
<>
|
||||
<video
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={item.thumbnailUrl || item.previewUrl}
|
||||
src={item.previewUrl}
|
||||
muted
|
||||
disablePictureInPicture
|
||||
/>
|
||||
<span className="client-media-card__play-icon">▶</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="client-media-card__processing">
|
||||
{item.processingStatus.toLowerCase()}
|
||||
@@ -325,7 +334,7 @@ function MediaCard({
|
||||
<span className="client-media-card__video-label">
|
||||
Film {formatDuration(item.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="client-media-card__image"
|
||||
|
||||
@@ -22,7 +22,9 @@ export function PhotoViewer({
|
||||
onDownload,
|
||||
}: PhotoViewerProps) {
|
||||
const startX = useRef<number | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const item = items[index];
|
||||
const isVideo = item?.mimeType.startsWith('video/');
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
@@ -35,7 +37,7 @@ export function PhotoViewer({
|
||||
useEffect(() => {
|
||||
[index - 1, index + 1].forEach((neighborIndex) => {
|
||||
const neighbor = items[neighborIndex];
|
||||
if (neighbor?.previewUrl) {
|
||||
if (neighbor?.previewUrl && !neighbor.mimeType.startsWith('video/')) {
|
||||
const image = new Image();
|
||||
image.src = neighbor.previewUrl;
|
||||
}
|
||||
@@ -101,7 +103,20 @@ export function PhotoViewer({
|
||||
exit={{ opacity: 0, scale: 1.02 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{item.previewUrl ? (
|
||||
{isVideo ? (
|
||||
item.previewUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="photo-viewer__video"
|
||||
src={item.previewUrl}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<span>Preview processing</span>
|
||||
)
|
||||
) : item.previewUrl ? (
|
||||
<img src={item.previewUrl} alt={item.originalFilename} draggable={false} />
|
||||
) : (
|
||||
<span>Preview processing</span>
|
||||
|
||||
@@ -34,7 +34,6 @@ export default function LoginPage() {
|
||||
<AuthLayout>
|
||||
<div className="auth-card">
|
||||
<div className="auth-card__heading">
|
||||
<p className="platform-kicker platform-kicker--accent">01 / Authenticate</p>
|
||||
<h2 className="platform-display">Sign In</h2>
|
||||
<p>Photographer console access.</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function RootPage() {
|
||||
return (
|
||||
<div className="root-shell">
|
||||
<div className="root-shell__center">
|
||||
<span className="root-shell__mark">N</span>
|
||||
<h1 className="root-shell__title">Noah Bianchi</h1>
|
||||
<div className="root-shell__actions">
|
||||
<Link className="platform-button platform-button--accent" to="/login">
|
||||
<span>Studio Access</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,6 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<p className="studio-kicker">{user?.name || 'Studio'} / Overview</p>
|
||||
<h1 className="studio-display">Dashboard</h1>
|
||||
<p className="studio-page__lede">Active delivery workspace.</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||
<span>New Gallery</span>
|
||||
@@ -81,15 +80,7 @@ export default function DashboardPage() {
|
||||
</section>
|
||||
|
||||
<section className="studio-section studio-section--recent">
|
||||
<div className="studio-section__heading">
|
||||
<div>
|
||||
<p className="studio-kicker">06 / Recent activity</p>
|
||||
<h2 className="studio-heading">Recent Galleries</h2>
|
||||
</div>
|
||||
<Link className="inline-link" to="/dashboard/galleries">
|
||||
View All <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="studio-section__heading"></span>
|
||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||
{loading ? (
|
||||
<div className="studio-list-loading">
|
||||
|
||||
@@ -35,9 +35,7 @@ export default function GalleriesPage() {
|
||||
<div className="studio-page">
|
||||
<header className="studio-page__header studio-page__header--compact">
|
||||
<div>
|
||||
<p className="studio-kicker">Workspace / Galleries</p>
|
||||
<h1 className="studio-display">Galleries</h1>
|
||||
<p className="studio-page__lede">All delivery records.</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||
<span>New Gallery</span>
|
||||
|
||||
@@ -91,6 +91,23 @@ export default function GalleryEditorPage() {
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [videoItem, setVideoItem] = useState<MediaItem | null>(null);
|
||||
const clientURL = gallery?.slug ? `${window.location.origin}/g/${gallery.slug}` : '';
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(clientURL);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
if (clientURL) window.open(clientURL, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
@@ -222,6 +239,27 @@ export default function GalleryEditorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMediaToIndex(fromIndex: number, toIndex: number) {
|
||||
if (!gallery) return;
|
||||
const items = [...gallery.media];
|
||||
const [moved] = items.splice(fromIndex, 1);
|
||||
items.splice(toIndex, 0, moved);
|
||||
setGallery((current) => (current ? { ...current, media: items } : current));
|
||||
try {
|
||||
const updates: { id: string; sortOrder: number }[] = [];
|
||||
items.forEach((item, i) => {
|
||||
const original = gallery.media.find((m) => m.id === item.id);
|
||||
if (original && original.sortOrder !== i) {
|
||||
updates.push({ id: item.id, sortOrder: i });
|
||||
}
|
||||
});
|
||||
await Promise.all(updates.map((u) => updateMediaOrder(u.id, u.sortOrder)));
|
||||
} catch (reason) {
|
||||
setGallery((current) => (current ? { ...current, media: gallery.media } : current));
|
||||
setError(reason instanceof Error ? reason.message : 'Could not reorder media.');
|
||||
}
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
@@ -253,6 +291,16 @@ export default function GalleryEditorPage() {
|
||||
Preview <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
)}
|
||||
{gallery?.status === 'published' && clientURL && (
|
||||
<>
|
||||
<button className="editor-button editor-button--quiet" type="button" onClick={() => void copyLink()}>
|
||||
{copied ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
<button className="editor-button editor-button--quiet" type="button" onClick={openLink}>
|
||||
Open <span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="editor-button editor-button--quiet"
|
||||
type="button"
|
||||
@@ -344,10 +392,7 @@ export default function GalleryEditorPage() {
|
||||
<section className="editor-section">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">02 / Media_Buffer</p>
|
||||
<h2>Upload_Files</h2>
|
||||
<p>
|
||||
Originals stay private in object storage. Previews are prepared in the background.
|
||||
</p>
|
||||
<h2>Upload Files</h2>
|
||||
</div>
|
||||
{gallery ? (
|
||||
<UploadDropzone
|
||||
@@ -361,7 +406,6 @@ export default function GalleryEditorPage() {
|
||||
/>
|
||||
) : (
|
||||
<div className="editor-locked">
|
||||
<span>01</span>
|
||||
<p>Save the gallery details above to start uploading work.</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -376,7 +420,9 @@ export default function GalleryEditorPage() {
|
||||
cover={draft.coverMediaId === item.id}
|
||||
onCover={() => setField('coverMediaId', item.id)}
|
||||
onMove={(direction) => void moveMedia(item, direction)}
|
||||
onMoveToIndex={(toIndex) => void moveMediaToIndex(index, toIndex)}
|
||||
onDelete={() => void removeMedia(item)}
|
||||
onOpenVideo={() => setVideoItem(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -387,11 +433,32 @@ export default function GalleryEditorPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{videoItem && (
|
||||
<div className="editor-video-viewer" role="dialog" aria-modal="true" onClick={() => setVideoItem(null)}>
|
||||
<button className="editor-video-viewer__close" type="button" onClick={() => setVideoItem(null)} aria-label="Close video">
|
||||
×
|
||||
</button>
|
||||
<div className="editor-video-viewer__stage" onClick={(e) => e.stopPropagation()}>
|
||||
{videoItem.previewUrl ? (
|
||||
<video
|
||||
className="editor-video-viewer__player"
|
||||
src={videoItem.previewUrl}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<p>Video is still processing.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<aside className="editor-aside">
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">03 / Client_Control</p>
|
||||
<h2>Access_Settings</h2>
|
||||
<p className="studio-kicker">03 / Client Control</p>
|
||||
<h2>Access Settings</h2>
|
||||
</div>
|
||||
<div className="toggle-list">
|
||||
<Toggle
|
||||
@@ -588,7 +655,9 @@ function EditorMediaTile({
|
||||
cover,
|
||||
onCover,
|
||||
onMove,
|
||||
onMoveToIndex,
|
||||
onDelete,
|
||||
onOpenVideo,
|
||||
}: {
|
||||
item: MediaItem;
|
||||
index: number;
|
||||
@@ -596,13 +665,58 @@ function EditorMediaTile({
|
||||
cover: boolean;
|
||||
onCover: () => void;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
onMoveToIndex: (toIndex: number) => void;
|
||||
onDelete: () => void;
|
||||
onOpenVideo?: () => void;
|
||||
}) {
|
||||
const isVideo = item.mimeType.startsWith('video/');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
function handleDragStart(event: React.DragEvent) {
|
||||
event.dataTransfer.setData('text/plain', String(index));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleDragOver(event: React.DragEvent) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
setDragOver(true);
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setDragOver(false);
|
||||
}
|
||||
|
||||
function handleDrop(event: React.DragEvent) {
|
||||
event.preventDefault();
|
||||
setDragOver(false);
|
||||
const fromIndex = parseInt(event.dataTransfer.getData('text/plain'), 10);
|
||||
if (fromIndex !== index && !isNaN(fromIndex)) {
|
||||
onMoveToIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`editor-media-tile ${cover ? 'is-cover' : ''}`}>
|
||||
<article
|
||||
className={`editor-media-tile ${cover ? 'is-cover' : ''} ${dragOver ? 'is-drag-over' : ''} ${isVideo ? 'editor-media-tile--video' : ''}`}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="editor-media-tile__image">
|
||||
{item.previewUrl ? (
|
||||
{isVideo ? (
|
||||
<button
|
||||
className="editor-media-tile__video-placeholder"
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenVideo?.(); }}
|
||||
aria-label="Play video"
|
||||
>
|
||||
<span className="editor-media-tile__play-icon">▶</span>
|
||||
<span>VIDEO</span>
|
||||
</button>
|
||||
) : item.previewUrl ? (
|
||||
<img src={item.previewUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="editor-media-tile__placeholder">
|
||||
@@ -610,8 +724,8 @@ function EditorMediaTile({
|
||||
</div>
|
||||
)}
|
||||
{isVideo && (
|
||||
<span className="editor-media-tile__video">
|
||||
VIDEO {formatDuration(item.durationSeconds)}
|
||||
<span className="editor-media-tile__video-label">
|
||||
Film {formatDuration(item.durationSeconds)}
|
||||
</span>
|
||||
)}
|
||||
{cover && <span className="editor-media-tile__cover">Cover</span>}
|
||||
|
||||
+3344
-3492
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user