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
|
||||
|
||||
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/
|
||||
EXPOSE 8080
|
||||
CMD ["server"]
|
||||
@@ -5,9 +5,15 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
@@ -70,23 +76,31 @@ func (p *Processor) process(mediaID uuid.UUID) {
|
||||
return
|
||||
}
|
||||
|
||||
if item.ExternalURL != "" || !IsImage(item) {
|
||||
if item.ExternalURL != "" {
|
||||
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height)
|
||||
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)
|
||||
if err != nil {
|
||||
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
|
||||
_ = p.repository.MarkFailed(ctx, item.ID, err.Error())
|
||||
return
|
||||
}
|
||||
defer object.Close()
|
||||
|
||||
decoded, _, err := image.Decode(io.LimitReader(object, 100<<20))
|
||||
if err != nil {
|
||||
// Formats without a stdlib decoder, such as HEIC, remain usable through
|
||||
// the original object until a dedicated processing service is added.
|
||||
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, 0, 0)
|
||||
_ = p.repository.MarkReady(ctx, item.ID, item.StorageKey, item.StorageKey, 0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,27 +108,132 @@ func (p *Processor) process(mediaID uuid.UUID) {
|
||||
height := decoded.Bounds().Dy()
|
||||
previewKey := variantKey(item, "preview.jpg")
|
||||
thumbnailKey := variantKey(item, "thumbnail.jpg")
|
||||
|
||||
preview, err := encodeJPEG(resize(decoded, 2400), 88)
|
||||
if err != nil {
|
||||
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
|
||||
_ = p.repository.MarkFailed(ctx, item.ID, err.Error())
|
||||
return
|
||||
}
|
||||
thumbnail, err := encodeJPEG(resize(decoded, 640), 84)
|
||||
if err != nil {
|
||||
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
|
||||
_ = p.repository.MarkFailed(ctx, item.ID, err.Error())
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
_ = 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 {
|
||||
@@ -152,3 +271,26 @@ func encodeJPEG(source image.Image, quality int) ([]byte, error) {
|
||||
}
|
||||
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 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,18 +1,21 @@
|
||||
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">
|
||||
<div className="auth-shell__content">
|
||||
<Link to="/" className="auth-shell__brand">
|
||||
<span className="platform-mark">N</span>
|
||||
<span>Noah Bianchi</span>
|
||||
</div>
|
||||
</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 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 ? (
|
||||
<button
|
||||
className="client-media-card__video-wrap"
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={!ready}
|
||||
>
|
||||
{ready && (item.thumbnailUrl || item.previewUrl) ? (
|
||||
<>
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={gallery.cover?.previewUrl}
|
||||
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>}
|
||||
|
||||
+3351
-3499
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user