update style

This commit is contained in:
2026-08-24 00:31:59 +02:00
parent e6805af3fe
commit 832c5d57dd
14 changed files with 3705 additions and 3563 deletions
+1 -1
View File
@@ -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"]
+152 -10
View File
@@ -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)
}
+2
View File
@@ -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 />} />
+5 -2
View File
@@ -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">&#8599;</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">&#9654;</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>
-1
View File
@@ -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>
+18
View File
@@ -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">&#8599;</span>
</Link>
</div>
</div>
</div>
);
}
+1 -10
View File
@@ -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">&#8599;</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">&#8599;</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">&#8599;</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">
&#215;
</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">&#9654;</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
View File
File diff suppressed because it is too large Load Diff