update style
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ RUN go build -o /usr/bin/migrate ./cmd/migrate
|
|||||||
RUN go build -o /usr/bin/seed ./cmd/seed
|
RUN go build -o /usr/bin/seed ./cmd/seed
|
||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates ffmpeg
|
||||||
COPY --from=builder /usr/bin/server /usr/bin/migrate /usr/bin/seed /usr/bin/
|
COPY --from=builder /usr/bin/server /usr/bin/migrate /usr/bin/seed /usr/bin/
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["server"]
|
CMD ["server"]
|
||||||
@@ -5,9 +5,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/example/sndit/backend/internal/storage"
|
"github.com/example/sndit/backend/internal/storage"
|
||||||
@@ -70,23 +76,31 @@ func (p *Processor) process(mediaID uuid.UUID) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.ExternalURL != "" || !IsImage(item) {
|
if item.ExternalURL != "" {
|
||||||
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height)
|
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if IsImage(item) {
|
||||||
|
p.processImage(ctx, item)
|
||||||
|
} else if IsVideo(item) {
|
||||||
|
p.processVideo(ctx, item)
|
||||||
|
} else {
|
||||||
|
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) processImage(ctx context.Context, item Record) {
|
||||||
object, err := p.storage.Get(ctx, item.StorageKey)
|
object, err := p.storage.Get(ctx, item.StorageKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
|
_ = p.repository.MarkFailed(ctx, item.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer object.Close()
|
defer object.Close()
|
||||||
|
|
||||||
decoded, _, err := image.Decode(io.LimitReader(object, 100<<20))
|
decoded, _, err := image.Decode(io.LimitReader(object, 100<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Formats without a stdlib decoder, such as HEIC, remain usable through
|
_ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, item.StorageKey, 0, 0)
|
||||||
// the original object until a dedicated processing service is added.
|
|
||||||
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, 0, 0)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,27 +108,132 @@ func (p *Processor) process(mediaID uuid.UUID) {
|
|||||||
height := decoded.Bounds().Dy()
|
height := decoded.Bounds().Dy()
|
||||||
previewKey := variantKey(item, "preview.jpg")
|
previewKey := variantKey(item, "preview.jpg")
|
||||||
thumbnailKey := variantKey(item, "thumbnail.jpg")
|
thumbnailKey := variantKey(item, "thumbnail.jpg")
|
||||||
|
|
||||||
preview, err := encodeJPEG(resize(decoded, 2400), 88)
|
preview, err := encodeJPEG(resize(decoded, 2400), 88)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
|
_ = p.repository.MarkFailed(ctx, item.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
thumbnail, err := encodeJPEG(resize(decoded, 640), 84)
|
thumbnail, err := encodeJPEG(resize(decoded, 640), 84)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
|
_ = p.repository.MarkFailed(ctx, item.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := p.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/jpeg"); err != nil {
|
if err := p.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/jpeg"); err != nil {
|
||||||
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
|
_ = p.repository.MarkFailed(ctx, item.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := p.storage.Put(ctx, thumbnailKey, bytes.NewReader(thumbnail), int64(len(thumbnail)), "image/jpeg"); err != nil {
|
if err := p.storage.Put(ctx, thumbnailKey, bytes.NewReader(thumbnail), int64(len(thumbnail)), "image/jpeg"); err != nil {
|
||||||
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
|
_ = p.repository.MarkFailed(ctx, item.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := p.repository.MarkReady(ctx, mediaID, previewKey, thumbnailKey, width, height); err != nil {
|
_ = p.repository.MarkReady(ctx, item.ID, previewKey, thumbnailKey, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) processVideo(ctx context.Context, item Record) {
|
||||||
|
thumbnailKey := variantKey(item, "thumbnail.jpg")
|
||||||
|
previewKey := variantKey(item, "preview.mp4")
|
||||||
|
|
||||||
|
if err := p.generateVideoThumbnail(ctx, item, thumbnailKey); err != nil {
|
||||||
|
p.log("video thumbnail failed for %s: %s — using fallback", item.ID, err)
|
||||||
|
p.generateFallbackThumbnail(ctx, item, thumbnailKey)
|
||||||
|
}
|
||||||
|
if err := p.generateVideoPreview(ctx, item, previewKey); err != nil {
|
||||||
|
p.log("video preview failed for %s: %s — using original", item.ID, err)
|
||||||
|
_ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, thumbnailKey, 0, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_ = p.repository.MarkReady(ctx, item.ID, previewKey, thumbnailKey, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) generateVideoThumbnail(ctx context.Context, item Record, key string) error {
|
||||||
|
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("sndit-%s", uuid.New()))
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
|
||||||
|
if err := p.downloadToFile(ctx, item.StorageKey, tmp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||||
|
"-ss", "0",
|
||||||
|
"-i", tmp,
|
||||||
|
"-vframes", "1",
|
||||||
|
"-q:v", "6",
|
||||||
|
"-f", "image2pipe",
|
||||||
|
"-c:v", "mjpeg",
|
||||||
|
"pipe:1",
|
||||||
|
)
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg thumbnail: %w, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.storage.Put(ctx, key, bytes.NewReader(output), int64(len(output)), "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) generateVideoPreview(ctx context.Context, item Record, key string) error {
|
||||||
|
tmpInput := filepath.Join(os.TempDir(), fmt.Sprintf("sndit-in-%s", uuid.New()))
|
||||||
|
tmpOutput := filepath.Join(os.TempDir(), fmt.Sprintf("sndit-out-%s.mp4", uuid.New()))
|
||||||
|
defer os.Remove(tmpInput)
|
||||||
|
defer os.Remove(tmpOutput)
|
||||||
|
|
||||||
|
if err := p.downloadToFile(ctx, item.StorageKey, tmpInput); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||||
|
"-i", tmpInput,
|
||||||
|
"-vf", "scale='min(720,iw)':-1:force_original_aspect_ratio=decrease,pad='ceil(iw/2)*2':'ceil(ih/2)*2':-1:-1:black",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "fast",
|
||||||
|
"-crf", "28",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "64k",
|
||||||
|
"-y", tmpOutput,
|
||||||
|
)
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg preview: %w, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(tmpOutput)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "video/mp4")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) downloadToFile(ctx context.Context, storageKey, tmp string) error {
|
||||||
|
object, err := p.storage.Get(ctx, storageKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get original: %w", err)
|
||||||
|
}
|
||||||
|
defer object.Close()
|
||||||
|
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(f, object); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("copy to temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("sync temp file: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func variantKey(item Record, filename string) string {
|
func variantKey(item Record, filename string) string {
|
||||||
@@ -152,3 +271,26 @@ func encodeJPEG(source image.Image, quality int) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return output.Bytes(), nil
|
return output.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Processor) log(format string, args ...any) {
|
||||||
|
log.Printf("[media-processor] "+format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) generateFallbackThumbnail(ctx context.Context, item Record, key string) {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 640, 480))
|
||||||
|
bg := color.RGBA{20, 20, 20, 255}
|
||||||
|
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
data, err := encodeJPEG(img, 60)
|
||||||
|
if err != nil {
|
||||||
|
p.log("fallback thumbnail encode failed: %s", err)
|
||||||
|
_ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, item.StorageKey, 0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil {
|
||||||
|
p.log("fallback thumbnail upload failed: %s", err)
|
||||||
|
_ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, item.StorageKey, 0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, key, 0, 0)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import GalleryPreviewPage from './pages/GalleryPreviewPage';
|
|||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import PublicGalleryPage from './pages/PublicGalleryPage';
|
import PublicGalleryPage from './pages/PublicGalleryPage';
|
||||||
import RegisterPage from './pages/RegisterPage';
|
import RegisterPage from './pages/RegisterPage';
|
||||||
|
import RootPage from './pages/RootPage';
|
||||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||||
import GalleriesPage from './pages/dashboard/GalleriesPage';
|
import GalleriesPage from './pages/dashboard/GalleriesPage';
|
||||||
import GalleryEditorPage from './pages/dashboard/GalleryEditorPage';
|
import GalleryEditorPage from './pages/dashboard/GalleryEditorPage';
|
||||||
@@ -19,6 +20,7 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/" element={<RootPage />} />
|
||||||
<Route path="/g/:slug" element={<PublicGalleryPage />} />
|
<Route path="/g/:slug" element={<PublicGalleryPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export function AuthLayout({ children }: { children: ReactNode }) {
|
export function AuthLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-shell">
|
<div className="auth-shell">
|
||||||
<div className="auth-shell__texture" aria-hidden="true" />
|
<div className="auth-shell__texture" aria-hidden="true" />
|
||||||
<div className="auth-shell__brand">
|
<div className="auth-shell__content">
|
||||||
|
<Link to="/" className="auth-shell__brand">
|
||||||
<span className="platform-mark">N</span>
|
<span className="platform-mark">N</span>
|
||||||
<span>Noah Bianchi</span>
|
<span>Noah Bianchi</span>
|
||||||
</div>
|
</Link>
|
||||||
<div className="auth-shell__aside">
|
<div className="auth-shell__aside">
|
||||||
<p className="platform-kicker">Private delivery system</p>
|
<p className="platform-kicker">Private delivery system</p>
|
||||||
<h1 className="platform-display">Studio Access</h1>
|
<h1 className="platform-display">Studio Access</h1>
|
||||||
<p>Photographer gallery console for client delivery.</p>
|
<p>Photographer gallery console for client delivery.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="auth-shell__panel">{children}</div>
|
<div className="auth-shell__panel">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,13 +45,6 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="studio-sidebar__bottom">
|
<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">
|
<div className="studio-account">
|
||||||
<span className="studio-account__avatar">{initials || 'N'}</span>
|
<span className="studio-account__avatar">{initials || 'N'}</span>
|
||||||
<span className="studio-account__details">
|
<span className="studio-account__details">
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export function GalleryCard({ gallery, onDelete }: GalleryCardProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLink() {
|
||||||
|
window.open(clientURL, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="gallery-card">
|
<article className="gallery-card">
|
||||||
<Link className="gallery-card__cover" to={`/dashboard/galleries/${gallery.id}/edit`}>
|
<Link className="gallery-card__cover" to={`/dashboard/galleries/${gallery.id}/edit`}>
|
||||||
@@ -61,13 +65,15 @@ export function GalleryCard({ gallery, onDelete }: GalleryCardProps) {
|
|||||||
Preview
|
Preview
|
||||||
</Link>
|
</Link>
|
||||||
{gallery.status === 'published' && (
|
{gallery.status === 'published' && (
|
||||||
<button
|
<>
|
||||||
className="text-action text-action--muted"
|
<button className="gallery-card__link-button" type="button" onClick={() => void copyLink()}>
|
||||||
type="button"
|
<span>{copied ? 'Copied' : 'Copy link'}</span>
|
||||||
onClick={() => void copyLink()}
|
|
||||||
>
|
|
||||||
{copied ? 'Copied' : 'Copy link'}
|
|
||||||
</button>
|
</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
|
<button
|
||||||
className="gallery-card__delete"
|
className="gallery-card__delete"
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openPhoto(item: MediaItem) {
|
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);
|
setViewerIndex(index >= 0 ? index : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{viewerIndex !== null && (
|
{viewerIndex !== null && (
|
||||||
<PhotoViewer
|
<PhotoViewer
|
||||||
items={photos}
|
items={gallery.media}
|
||||||
index={viewerIndex}
|
index={viewerIndex}
|
||||||
onClose={() => setViewerIndex(null)}
|
onClose={() => setViewerIndex(null)}
|
||||||
onChange={setViewerIndex}
|
onChange={setViewerIndex}
|
||||||
@@ -308,15 +308,24 @@ function MediaCard({
|
|||||||
className={`client-media-card ${isVideo ? 'client-media-card--video' : ''} ${gallery.watermarkEnabled ? 'client-media-card--watermarked' : ''}`}
|
className={`client-media-card ${isVideo ? 'client-media-card--video' : ''} ${gallery.watermarkEnabled ? 'client-media-card--watermarked' : ''}`}
|
||||||
>
|
>
|
||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<div className="client-media-card__video-wrap">
|
<button
|
||||||
{ready && item.previewUrl ? (
|
className="client-media-card__video-wrap"
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
disabled={!ready}
|
||||||
|
>
|
||||||
|
{ready && (item.thumbnailUrl || item.previewUrl) ? (
|
||||||
|
<>
|
||||||
<video
|
<video
|
||||||
controls
|
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
poster={gallery.cover?.previewUrl}
|
poster={item.thumbnailUrl || item.previewUrl}
|
||||||
src={item.previewUrl}
|
src={item.previewUrl}
|
||||||
|
muted
|
||||||
|
disablePictureInPicture
|
||||||
/>
|
/>
|
||||||
|
<span className="client-media-card__play-icon">▶</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="client-media-card__processing">
|
<div className="client-media-card__processing">
|
||||||
{item.processingStatus.toLowerCase()}
|
{item.processingStatus.toLowerCase()}
|
||||||
@@ -325,7 +334,7 @@ function MediaCard({
|
|||||||
<span className="client-media-card__video-label">
|
<span className="client-media-card__video-label">
|
||||||
Film {formatDuration(item.durationSeconds)}
|
Film {formatDuration(item.durationSeconds)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="client-media-card__image"
|
className="client-media-card__image"
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export function PhotoViewer({
|
|||||||
onDownload,
|
onDownload,
|
||||||
}: PhotoViewerProps) {
|
}: PhotoViewerProps) {
|
||||||
const startX = useRef<number | null>(null);
|
const startX = useRef<number | null>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
|
const isVideo = item?.mimeType.startsWith('video/');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
@@ -35,7 +37,7 @@ export function PhotoViewer({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
[index - 1, index + 1].forEach((neighborIndex) => {
|
[index - 1, index + 1].forEach((neighborIndex) => {
|
||||||
const neighbor = items[neighborIndex];
|
const neighbor = items[neighborIndex];
|
||||||
if (neighbor?.previewUrl) {
|
if (neighbor?.previewUrl && !neighbor.mimeType.startsWith('video/')) {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = neighbor.previewUrl;
|
image.src = neighbor.previewUrl;
|
||||||
}
|
}
|
||||||
@@ -101,7 +103,20 @@ export function PhotoViewer({
|
|||||||
exit={{ opacity: 0, scale: 1.02 }}
|
exit={{ opacity: 0, scale: 1.02 }}
|
||||||
transition={{ duration: 0.25 }}
|
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} />
|
<img src={item.previewUrl} alt={item.originalFilename} draggable={false} />
|
||||||
) : (
|
) : (
|
||||||
<span>Preview processing</span>
|
<span>Preview processing</span>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export default function LoginPage() {
|
|||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<div className="auth-card__heading">
|
<div className="auth-card__heading">
|
||||||
<p className="platform-kicker platform-kicker--accent">01 / Authenticate</p>
|
|
||||||
<h2 className="platform-display">Sign In</h2>
|
<h2 className="platform-display">Sign In</h2>
|
||||||
<p>Photographer console access.</p>
|
<p>Photographer console access.</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p className="studio-kicker">{user?.name || 'Studio'} / Overview</p>
|
<p className="studio-kicker">{user?.name || 'Studio'} / Overview</p>
|
||||||
<h1 className="studio-display">Dashboard</h1>
|
<h1 className="studio-display">Dashboard</h1>
|
||||||
<p className="studio-page__lede">Active delivery workspace.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||||
<span>New Gallery</span>
|
<span>New Gallery</span>
|
||||||
@@ -81,15 +80,7 @@ export default function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="studio-section studio-section--recent">
|
<section className="studio-section studio-section--recent">
|
||||||
<div className="studio-section__heading">
|
<span className="studio-section__heading"></span>
|
||||||
<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>
|
|
||||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="studio-list-loading">
|
<div className="studio-list-loading">
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ export default function GalleriesPage() {
|
|||||||
<div className="studio-page">
|
<div className="studio-page">
|
||||||
<header className="studio-page__header studio-page__header--compact">
|
<header className="studio-page__header studio-page__header--compact">
|
||||||
<div>
|
<div>
|
||||||
<p className="studio-kicker">Workspace / Galleries</p>
|
|
||||||
<h1 className="studio-display">Galleries</h1>
|
<h1 className="studio-display">Galleries</h1>
|
||||||
<p className="studio-page__lede">All delivery records.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||||
<span>New Gallery</span>
|
<span>New Gallery</span>
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ export default function GalleryEditorPage() {
|
|||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
const [error, setError] = 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(() => {
|
useEffect(() => {
|
||||||
if (isNew) return;
|
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>) {
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void save();
|
void save();
|
||||||
@@ -253,6 +291,16 @@ export default function GalleryEditorPage() {
|
|||||||
Preview <span aria-hidden="true">↗</span>
|
Preview <span aria-hidden="true">↗</span>
|
||||||
</Link>
|
</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
|
<button
|
||||||
className="editor-button editor-button--quiet"
|
className="editor-button editor-button--quiet"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -344,10 +392,7 @@ export default function GalleryEditorPage() {
|
|||||||
<section className="editor-section">
|
<section className="editor-section">
|
||||||
<div className="editor-section__heading">
|
<div className="editor-section__heading">
|
||||||
<p className="studio-kicker">02 / Media_Buffer</p>
|
<p className="studio-kicker">02 / Media_Buffer</p>
|
||||||
<h2>Upload_Files</h2>
|
<h2>Upload Files</h2>
|
||||||
<p>
|
|
||||||
Originals stay private in object storage. Previews are prepared in the background.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{gallery ? (
|
{gallery ? (
|
||||||
<UploadDropzone
|
<UploadDropzone
|
||||||
@@ -361,7 +406,6 @@ export default function GalleryEditorPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="editor-locked">
|
<div className="editor-locked">
|
||||||
<span>01</span>
|
|
||||||
<p>Save the gallery details above to start uploading work.</p>
|
<p>Save the gallery details above to start uploading work.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -376,7 +420,9 @@ export default function GalleryEditorPage() {
|
|||||||
cover={draft.coverMediaId === item.id}
|
cover={draft.coverMediaId === item.id}
|
||||||
onCover={() => setField('coverMediaId', item.id)}
|
onCover={() => setField('coverMediaId', item.id)}
|
||||||
onMove={(direction) => void moveMedia(item, direction)}
|
onMove={(direction) => void moveMedia(item, direction)}
|
||||||
|
onMoveToIndex={(toIndex) => void moveMediaToIndex(index, toIndex)}
|
||||||
onDelete={() => void removeMedia(item)}
|
onDelete={() => void removeMedia(item)}
|
||||||
|
onOpenVideo={() => setVideoItem(item)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -387,11 +433,32 @@ export default function GalleryEditorPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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">
|
<aside className="editor-aside">
|
||||||
<section className="editor-section editor-section--aside">
|
<section className="editor-section editor-section--aside">
|
||||||
<div className="editor-section__heading">
|
<div className="editor-section__heading">
|
||||||
<p className="studio-kicker">03 / Client_Control</p>
|
<p className="studio-kicker">03 / Client Control</p>
|
||||||
<h2>Access_Settings</h2>
|
<h2>Access Settings</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="toggle-list">
|
<div className="toggle-list">
|
||||||
<Toggle
|
<Toggle
|
||||||
@@ -588,7 +655,9 @@ function EditorMediaTile({
|
|||||||
cover,
|
cover,
|
||||||
onCover,
|
onCover,
|
||||||
onMove,
|
onMove,
|
||||||
|
onMoveToIndex,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpenVideo,
|
||||||
}: {
|
}: {
|
||||||
item: MediaItem;
|
item: MediaItem;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -596,13 +665,58 @@ function EditorMediaTile({
|
|||||||
cover: boolean;
|
cover: boolean;
|
||||||
onCover: () => void;
|
onCover: () => void;
|
||||||
onMove: (direction: -1 | 1) => void;
|
onMove: (direction: -1 | 1) => void;
|
||||||
|
onMoveToIndex: (toIndex: number) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onOpenVideo?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isVideo = item.mimeType.startsWith('video/');
|
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 (
|
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">
|
<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" />
|
<img src={item.previewUrl} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<div className="editor-media-tile__placeholder">
|
<div className="editor-media-tile__placeholder">
|
||||||
@@ -610,8 +724,8 @@ function EditorMediaTile({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<span className="editor-media-tile__video">
|
<span className="editor-media-tile__video-label">
|
||||||
VIDEO {formatDuration(item.durationSeconds)}
|
Film {formatDuration(item.durationSeconds)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{cover && <span className="editor-media-tile__cover">Cover</span>}
|
{cover && <span className="editor-media-tile__cover">Cover</span>}
|
||||||
|
|||||||
+3351
-3499
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user