This commit is contained in:
@@ -23,3 +23,7 @@ Dockerfile*
|
||||
.github
|
||||
migrations
|
||||
README.md
|
||||
uploads
|
||||
.git
|
||||
*.tar
|
||||
*.sql
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
cd /opt/vfxreview
|
||||
|
||||
git pull
|
||||
|
||||
docker-compose up -d --build
|
||||
|
||||
docker-compose exec -T vfxreview npx prisma migrate deploy
|
||||
|
||||
docker image prune -f
|
||||
@@ -103,6 +103,8 @@ const updateShotSchema = z.object({
|
||||
dueDate: z.string().optional().nullable(),
|
||||
artistId: z.string().cuid().optional().nullable().or(z.literal("")),
|
||||
thumbnailUrl: z.string().optional().nullable(),
|
||||
originalFootageUrl: z.string().optional().nullable(),
|
||||
originalFootageKey: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export async function updateShot(data: z.infer<typeof updateShotSchema>) {
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
CheckCircle2,
|
||||
Settings,
|
||||
ListTodo,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||
import { FootageViewer } from "@/components/shots/FootageViewer";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
@@ -51,7 +53,7 @@ export default function ShotDetailPage() {
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [artists, setArtists] = useState<any[]>([]);
|
||||
const [canManage, setCanManage] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"tasks" | "settings">("tasks");
|
||||
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
|
||||
|
||||
const fetchShot = async () => {
|
||||
try {
|
||||
@@ -190,6 +192,18 @@ export default function ShotDetailPage() {
|
||||
<ListTodo className="h-4 w-4" />
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("footage")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "footage"
|
||||
? "border-amber-500 text-amber-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
Footage
|
||||
</button>
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => setActiveTab("settings")}
|
||||
@@ -217,6 +231,14 @@ export default function ShotDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "footage" && (
|
||||
<FootageViewer
|
||||
shot={shot}
|
||||
canManage={canManage}
|
||||
onSaved={fetchShot}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && canManage && (
|
||||
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
|
||||
)}
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
root@ubuntu-4gb-nbg1-3:/opt/vfxreview# docker inspect vfxreview
|
||||
[
|
||||
{
|
||||
"Id": "c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320",
|
||||
"Created": "2026-05-15T11:13:46.919887274Z",
|
||||
"Path": "docker-entrypoint.sh",
|
||||
"Args": [
|
||||
"node",
|
||||
"server.js"
|
||||
],
|
||||
"State": {
|
||||
"Status": "restarting",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": true,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 0,
|
||||
"ExitCode": 255,
|
||||
"Error": "",
|
||||
"StartedAt": "2026-05-15T11:17:31.349008071Z",
|
||||
"FinishedAt": "2026-05-15T11:17:31.553910724Z"
|
||||
},
|
||||
"Image": "sha256:69368bb47b96170569bd23be2343dded31696127c8d1e0c1ddff6fbc6b9d4fb2",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320-json.log",
|
||||
"Name": "/vfxreview",
|
||||
"RestartCount": 13,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "docker-default",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": [
|
||||
"/opt/vfxreview/uploads:/uploads:rw"
|
||||
],
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "vfxreview_app-net",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "unless-stopped",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": [],
|
||||
"ConsoleSize": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": null,
|
||||
"DnsOptions": null,
|
||||
"DnsSearch": null,
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 0,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": null,
|
||||
"BlkioDeviceReadBps": null,
|
||||
"BlkioDeviceWriteBps": null,
|
||||
"BlkioDeviceReadIOps": null,
|
||||
"BlkioDeviceWriteIOps": null,
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": null,
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": null,
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/acpi",
|
||||
"/proc/asound",
|
||||
"/proc/interrupts",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/sys/devices/virtual/powercap",
|
||||
"/sys/firmware"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320",
|
||||
"LowerDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c-init/diff:/var/lib/docker/overlay2/99da5f835f83c7550d6a8f82f03fc4f7147c86a02f934cc8688fc5444110352c/diff:/var/lib/docker/overlay2/a824d9563ca0af7794e667c9b4799f57c6a76dcff84b0f6375018dc90c4f50ce/diff:/var/lib/docker/overlay2/6c9a5c50a54b3111494f6780dbb640559b6db9310b92826e875fd4d3048bc590/diff:/var/lib/docker/overlay2/ba53a31b5ba6e15b84be1424c9fb2053a16dbba42a2056b39cc0c0ca68a23ea3/diff:/var/lib/docker/overlay2/5a6252acf39dbbb2daa9a95050adbd5d61f7079c3ca281544a72d7942bdb86fa/diff:/var/lib/docker/overlay2/2fec287c58bf77e6f80c042a360d9814b77a747b1c6436ba68b4dcee309b8bb4/diff:/var/lib/docker/overlay2/100154f217c53a8e26927fa82f3fe56074e192506607c283a0cc3dfd7d4261a7/diff:/var/lib/docker/overlay2/c8d0b59c20926f8c6337ab778303dd37701740dcab6494a8ef7547eccfd266ed/diff:/var/lib/docker/overlay2/adb4f687409d280798957b268c1eef0fcc93ab6ecc24f291ba2ff4afaf5ef9c1/diff:/var/lib/docker/overlay2/0567d27adf4684c0b9d2be40dd32e2be2a81e43d2b118c8ac10697f64ce2b41c/diff:/var/lib/docker/overlay2/509a8569764702e18a334b6c7dd49e7dfdf237b821bcd34fc9102c11378e6c2b/diff:/var/lib/docker/overlay2/66cb5fe679daa03d24693ef170278322206904e36cad21c55a582175c7ea2298/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/opt/vfxreview/uploads",
|
||||
"Destination": "/uploads",
|
||||
"Mode": "rw",
|
||||
"RW": true,
|
||||
"Propagation": "rprivate"
|
||||
}
|
||||
],
|
||||
"Config": {
|
||||
"Hostname": "c657a3a390f6",
|
||||
"Domainname": "",
|
||||
"User": "nextjs",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"ExposedPorts": {
|
||||
"3000/tcp": {}
|
||||
},
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"DATABASE_URL=postgresql://postgres:postgres@postgres:5432/feedback",
|
||||
"NEXTAUTH_SECRET=your-secret-here-change-in-production",
|
||||
"NEXTAUTH_URL=http://localhost:3000",
|
||||
"UPLOADTHING_SECRET=eyJhcGlLZXkiOiJza19saXZlXzM0YmMzMTQ0NmVkZTJlMDQ3NmUwYmMzY2IyYzJkNTAyOTM1ODk0ZmM0YWRiNTQ1ODIxODhhM2VjNmU5OTE2NGMiLCJhcHBJZCI6ImVrN20xbWg2cXUiLCJyZWdpb25zIjpbInNlYTEiXX0=",
|
||||
"UPLOADTHING_APP_ID=",
|
||||
"STORAGE_PROVIDER=local",
|
||||
"AWS_ACCESS_KEY_ID=",
|
||||
"AWS_SECRET_ACCESS_KEY=",
|
||||
"AWS_REGION=us-east-1",
|
||||
"AWS_BUCKET_NAME=",
|
||||
"R2_ACCESS_KEY_ID=",
|
||||
"R2_SECRET_ACCESS_KEY=",
|
||||
"R2_ACCOUNT_ID=",
|
||||
"R2_BUCKET_NAME=",
|
||||
"R2_PUBLIC_URL=",
|
||||
"B2_APPLICATION_KEY_ID=",
|
||||
"B2_APPLICATION_KEY=",
|
||||
"B2_BUCKET_NAME=",
|
||||
"B2_ENDPOINT=",
|
||||
"MINIO_ENDPOINT=http://localhost:9000",
|
||||
"MINIO_ACCESS_KEY=",
|
||||
"MINIO_SECRET_KEY=",
|
||||
"MINIO_BUCKET_NAME=vfx-review",
|
||||
"LOCAL_UPLOAD_DIR=./uploads",
|
||||
"EMAIL_FROM=noreply@yourcompany.com",
|
||||
"EMAIL_SERVER_HOST=smtp.gmail.com",
|
||||
"EMAIL_SERVER_PORT=587",
|
||||
"EMAIL_SERVER_USER=",
|
||||
"EMAIL_SERVER_PASSWORD=",
|
||||
"SLACK_DEFAULT_WEBHOOK=",
|
||||
"NEXT_PUBLIC_APP_URL=http://localhost:3000",
|
||||
"NEXT_PUBLIC_APP_NAME=VFX Review",
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"NODE_VERSION=20.20.2",
|
||||
"YARN_VERSION=1.22.22",
|
||||
"NODE_ENV=production",
|
||||
"NEXT_TELEMETRY_DISABLED=1",
|
||||
"PORT=3000",
|
||||
"HOSTNAME=0.0.0.0"
|
||||
],
|
||||
"Cmd": [
|
||||
"node",
|
||||
"server.js"
|
||||
],
|
||||
"Image": "vfxreview:1.0.0",
|
||||
"Volumes": {
|
||||
"/uploads": {}
|
||||
},
|
||||
"WorkingDir": "/app",
|
||||
"Entrypoint": [
|
||||
"docker-entrypoint.sh"
|
||||
],
|
||||
"Labels": {
|
||||
"com.docker.compose.config-hash": "be61c44b05bc31b228b3b1ab25372b6e4e6b8358b1bc7c96a2b131e642a81358",
|
||||
"com.docker.compose.container-number": "1",
|
||||
"com.docker.compose.oneoff": "False",
|
||||
"com.docker.compose.project": "vfxreview",
|
||||
"com.docker.compose.project.config_files": "docker-compose.yml",
|
||||
"com.docker.compose.project.working_dir": "/opt/vfxreview",
|
||||
"com.docker.compose.service": "vfxreview",
|
||||
"com.docker.compose.version": "1.29.2"
|
||||
}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"SandboxID": "",
|
||||
"SandboxKey": "",
|
||||
"Ports": {},
|
||||
"Networks": {
|
||||
"vfxreview_app-net": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": [
|
||||
"vfxreview",
|
||||
"c657a3a390f6"
|
||||
],
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "e3f79c0c30cca1c12a74793b70818cc7573e9ec6ffa5ee393d3a81b26bae33ee",
|
||||
"EndpointID": "",
|
||||
"Gateway": "",
|
||||
"IPAddress": "",
|
||||
"MacAddress": "",
|
||||
"IPPrefixLen": 0,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": [
|
||||
"vfxreview",
|
||||
"c657a3a390f6"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Video, Upload, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { updateShot } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
interface FootageViewerProps {
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
originalFootageUrl: string | null;
|
||||
originalFootageKey: string | null;
|
||||
};
|
||||
canManage: boolean;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
function uploadViaXhr(
|
||||
file: File,
|
||||
onProgress: (fraction: number) => void
|
||||
): Promise<{ url: string; key: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/upload/local");
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
if (json.url) resolve({ url: json.url, key: json.key ?? "" });
|
||||
else reject(new Error(json.error ?? "Upload failed"));
|
||||
} catch {
|
||||
reject(new Error("Invalid server response"));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
reject(new Error(json.error ?? `HTTP ${xhr.status}`));
|
||||
} catch {
|
||||
reject(new Error(`HTTP ${xhr.status}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => reject(new Error("Network error")));
|
||||
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps) {
|
||||
const { toast } = useToast();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [footageFile, setFootageFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentUrl, setCurrentUrl] = useState<string | null>(shot.originalFootageUrl ?? null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setFootageFile(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!footageFile) return;
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
const { url, key } = await uploadViaXhr(footageFile, setProgress);
|
||||
await updateShot({
|
||||
shotId: shot.id,
|
||||
originalFootageUrl: url,
|
||||
originalFootageKey: key || undefined,
|
||||
});
|
||||
setCurrentUrl(url);
|
||||
setFootageFile(null);
|
||||
toast({ title: "Footage uploaded" });
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Upload failed",
|
||||
description: e instanceof Error ? e.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
try {
|
||||
await updateShot({ shotId: shot.id, originalFootageUrl: null, originalFootageKey: null });
|
||||
setCurrentUrl(null);
|
||||
setFootageFile(null);
|
||||
toast({ title: "Footage removed" });
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to remove footage",
|
||||
description: e instanceof Error ? e.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300 flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-amber-500" />
|
||||
Original Footage
|
||||
<span className="text-xs font-mono text-zinc-500">{shot.shotCode}</span>
|
||||
</h3>
|
||||
{canManage && currentUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-zinc-500 hover:text-red-400"
|
||||
onClick={handleRemove}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video player */}
|
||||
{currentUrl && (
|
||||
<div className="relative w-full rounded-xl overflow-hidden bg-black border border-zinc-800 aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={currentUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
preload="metadata"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!currentUrl && !footageFile && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center py-20 gap-4 rounded-xl border-2 border-dashed border-zinc-800 text-zinc-500 ${
|
||||
canManage ? "hover:border-amber-500/40 cursor-pointer transition-colors" : ""
|
||||
}`}
|
||||
onClick={() => canManage && fileInputRef.current?.click()}
|
||||
>
|
||||
<Video className="h-12 w-12 opacity-30" />
|
||||
<p className="text-sm">No original footage uploaded yet.</p>
|
||||
{canManage && <p className="text-xs text-zinc-600">Click to select a video file</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending file — ready to upload */}
|
||||
{footageFile && !uploading && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<Video className="h-5 w-5 text-zinc-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-300 truncate">{footageFile.name}</p>
|
||||
<p className="text-xs text-zinc-500">{(footageFile.size / 1024 / 1024).toFixed(1)} MB</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleUpload}>
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
Upload
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFootageFile(null)}
|
||||
className="text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploading && (
|
||||
<div className="space-y-2 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||
<span>Uploading footage…</span>
|
||||
<span>{Math.round(progress * 100)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 transition-all duration-200"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface FootageViewerProps {
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
fps: number;
|
||||
frameStart?: number | null;
|
||||
frameEnd?: number | null;
|
||||
dueDate: Date | string | null;
|
||||
artistId: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
originalFootageUrl: string | null;
|
||||
originalFootageKey: string | null;
|
||||
};
|
||||
canManage: boolean;
|
||||
artists: { id: string; name: string | null; email: string }[];
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function FootageViewer({ shot, canManage, artists, onSaved }: FootageViewerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
if (!shot.originalFootageUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4 text-zinc-500">
|
||||
<Video className="h-12 w-12 opacity-30" />
|
||||
<p className="text-sm">No original footage uploaded yet.</p>
|
||||
{canManage && !showUpload && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowUpload(true)}>
|
||||
Upload Footage
|
||||
</Button>
|
||||
)}
|
||||
{canManage && showUpload && (
|
||||
<div className="w-full max-w-2xl mt-4">
|
||||
<ShotSettingsTab
|
||||
shot={shot}
|
||||
artists={artists}
|
||||
onSaved={() => { setShowUpload(false); onSaved?.(); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300 flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-amber-500" />
|
||||
Original Footage
|
||||
<span className="text-xs font-mono text-zinc-500">{shot.shotCode}</span>
|
||||
</h3>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||||
onClick={() => setShowUpload((v) => !v)}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5 mr-1.5" />
|
||||
{showUpload ? "Hide" : "Replace"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video player */}
|
||||
<div className="relative w-full rounded-xl overflow-hidden bg-black border border-zinc-800 aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={shot.originalFootageUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
preload="metadata"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
{canManage && showUpload && (
|
||||
<div className="mt-6 max-w-2xl">
|
||||
<ShotSettingsTab
|
||||
shot={shot}
|
||||
artists={artists}
|
||||
onSaved={() => { setShowUpload(false); onSaved?.(); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,455 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { updateShot } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Upload, X, Film, ImageIcon } from "lucide-react";
|
||||
import { Upload, X, Film, ImageIcon, Video } from "lucide-react";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["WAITING", "IN_PROGRESS", "IN_REVIEW", "REVISIONS", "COMPLETE"]),
|
||||
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]),
|
||||
fps: z.coerce.number().min(1).max(240),
|
||||
frameStart: z.coerce.number().int().optional().or(z.literal("")),
|
||||
frameEnd: z.coerce.number().int().optional().or(z.literal("")),
|
||||
dueDate: z.string().optional(),
|
||||
artistId: z.string().optional(),
|
||||
});
|
||||
|
||||
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ShotSettingsTabProps {
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
fps: number;
|
||||
frameStart?: number | null;
|
||||
frameEnd?: number | null;
|
||||
dueDate: Date | string | null;
|
||||
artistId: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
originalFootageUrl: string | null;
|
||||
originalFootageKey: string | null;
|
||||
};
|
||||
artists: Artist[];
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
function uploadViaXhr(
|
||||
file: File,
|
||||
onProgress: (fraction: number) => void
|
||||
): Promise<{ url: string; key: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/upload/local");
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
if (json.url) resolve({ url: json.url, key: json.key ?? "" });
|
||||
else reject(new Error(json.error ?? "Upload failed"));
|
||||
} catch {
|
||||
reject(new Error("Invalid server response"));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
reject(new Error(json.error ?? `HTTP ${xhr.status}`));
|
||||
} catch {
|
||||
reject(new Error(`HTTP ${xhr.status}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => reject(new Error("Network error")));
|
||||
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(shot.thumbnailUrl ?? null);
|
||||
const [clearThumbnail, setClearThumbnail] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Footage upload state
|
||||
const [footageFile, setFootageFile] = useState<File | null>(null);
|
||||
const [footageUploading, setFootageUploading] = useState(false);
|
||||
const [footageProgress, setFootageProgress] = useState(0);
|
||||
const [currentFootageUrl, setCurrentFootageUrl] = useState<string | null>(shot.originalFootageUrl ?? null);
|
||||
const footageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const formatDate = (d: Date | string | null) => {
|
||||
if (!d) return "";
|
||||
return new Date(d).toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<SettingsFormValues>({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: {
|
||||
description: shot.description ?? "",
|
||||
status: shot.status as SettingsFormValues["status"],
|
||||
priority: shot.priority as SettingsFormValues["priority"],
|
||||
fps: shot.fps,
|
||||
frameStart: shot.frameStart ?? "",
|
||||
frameEnd: shot.frameEnd ?? "",
|
||||
dueDate: formatDate(shot.dueDate),
|
||||
artistId: shot.artistId ?? "__none__",
|
||||
},
|
||||
});
|
||||
|
||||
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setThumbnailFile(file);
|
||||
setClearThumbnail(false);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setThumbnailPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleFootageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setFootageFile(file);
|
||||
};
|
||||
|
||||
const handleFootageUpload = async () => {
|
||||
if (!footageFile) return;
|
||||
setFootageUploading(true);
|
||||
setFootageProgress(0);
|
||||
try {
|
||||
const { url, key } = await uploadViaXhr(footageFile, setFootageProgress);
|
||||
await updateShot({
|
||||
shotId: shot.id,
|
||||
originalFootageUrl: url,
|
||||
originalFootageKey: key || undefined,
|
||||
});
|
||||
setCurrentFootageUrl(url);
|
||||
setFootageFile(null);
|
||||
toast({ title: "Footage uploaded" });
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
toast({ title: "Upload failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setFootageUploading(false);
|
||||
setFootageProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFootageRemove = async () => {
|
||||
try {
|
||||
await updateShot({ shotId: shot.id, originalFootageUrl: null, originalFootageKey: null });
|
||||
setCurrentFootageUrl(null);
|
||||
setFootageFile(null);
|
||||
toast({ title: "Footage removed" });
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
toast({ title: "Failed to remove footage", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: SettingsFormValues) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let thumbnailUrl: string | null | undefined = undefined;
|
||||
|
||||
if (clearThumbnail) {
|
||||
thumbnailUrl = null;
|
||||
} else if (thumbnailFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", thumbnailFile);
|
||||
fd.append("type", "image");
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||
if (!res.ok) throw new Error("Thumbnail upload failed");
|
||||
const data = await res.json();
|
||||
thumbnailUrl = data.url;
|
||||
}
|
||||
|
||||
await updateShot({
|
||||
shotId: shot.id,
|
||||
description: values.description || undefined,
|
||||
status: values.status,
|
||||
priority: values.priority,
|
||||
fps: values.fps,
|
||||
frameStart: values.frameStart !== "" && values.frameStart != null ? Number(values.frameStart) : null,
|
||||
frameEnd: values.frameEnd !== "" && values.frameEnd != null ? Number(values.frameEnd) : null,
|
||||
dueDate: values.dueDate || null,
|
||||
artistId: values.artistId === "__none__" ? null : values.artistId,
|
||||
thumbnailUrl,
|
||||
});
|
||||
|
||||
toast({ title: "Shot updated" });
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
toast({ title: "Failed to save", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10 max-w-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<Film className="h-4 w-4 text-amber-500" />
|
||||
Details
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register("description")} rows={3} placeholder="Shot description…" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
defaultValue={shot.status}
|
||||
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"])}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="WAITING">Waiting</SelectItem>
|
||||
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
|
||||
<SelectItem value="IN_REVIEW">In Review</SelectItem>
|
||||
<SelectItem value="REVISIONS">Revisions</SelectItem>
|
||||
<SelectItem value="COMPLETE">Complete</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
defaultValue={shot.priority}
|
||||
onValueChange={(v) => setValue("priority", v as SettingsFormValues["priority"])}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOW">Low</SelectItem>
|
||||
<SelectItem value="NORMAL">Normal</SelectItem>
|
||||
<SelectItem value="HIGH">High</SelectItem>
|
||||
<SelectItem value="CRITICAL">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<span className="text-amber-500 font-mono text-xs">FPS</span>
|
||||
Timing
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>FPS</Label>
|
||||
<Input type="number" step="any" {...register("fps")} />
|
||||
{errors.fps && <p className="text-xs text-destructive">{errors.fps.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Frame Start</Label>
|
||||
<Input type="number" {...register("frameStart")} placeholder="1001" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Frame End</Label>
|
||||
<Input type="number" {...register("frameEnd")} placeholder="1100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-xs">
|
||||
<Label>Due Date</Label>
|
||||
<Input type="date" {...register("dueDate")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignment */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<span className="text-amber-500">👤</span>
|
||||
Assignment
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1.5 max-w-xs">
|
||||
<Label>Artist</Label>
|
||||
<Select
|
||||
defaultValue={shot.artistId ?? "__none__"}
|
||||
onValueChange={(v) => setValue("artistId", v)}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Unassigned" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Unassigned</SelectItem>
|
||||
{artists.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name ?? a.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<ImageIcon className="h-4 w-4 text-amber-500" />
|
||||
Thumbnail
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{thumbnailPreview && !clearThumbnail ? (
|
||||
<div className="relative w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border group">
|
||||
<Image src={thumbnailPreview} alt={shot.shotCode} fill className="object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setClearThumbnail(true); setThumbnailPreview(null); setThumbnailFile(null); }}
|
||||
className="absolute top-1.5 right-1.5 bg-black/70 hover:bg-black text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-72 aspect-[2.39] rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 flex items-center justify-center gap-2 text-sm text-muted-foreground cursor-pointer transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload thumbnail
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleThumbnailChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Saving…" : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Original Footage — separate section with its own XHR upload */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<Video className="h-4 w-4 text-amber-500" />
|
||||
Original Footage
|
||||
</div>
|
||||
<Separator />
|
||||
<p className="text-xs text-zinc-500">Upload the original (pre-VFX) footage for reference. Supported formats: MP4, MOV, AVI, MKV, WebM.</p>
|
||||
|
||||
{currentFootageUrl ? (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<Video className="h-5 w-5 text-amber-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-300 truncate flex-1">Footage uploaded</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => footageInputRef.current?.click()}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-zinc-500 hover:text-red-400"
|
||||
onClick={handleFootageRemove}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => !footageUploading && footageInputRef.current?.click()}
|
||||
className="w-full rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 flex flex-col items-center justify-center gap-2 py-8 text-sm text-muted-foreground cursor-pointer transition-colors"
|
||||
>
|
||||
<Video className="h-6 w-6" />
|
||||
<span>Click to select original footage</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footageFile && !footageUploading && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<Video className="h-5 w-5 text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-300 truncate flex-1">{footageFile.name}</span>
|
||||
<span className="text-xs text-zinc-500 shrink-0">{(footageFile.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<Button type="button" size="sm" onClick={handleFootageUpload}>
|
||||
Upload
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFootageFile(null)}
|
||||
className="text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footageUploading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||
<span>Uploading…</span>
|
||||
<span>{Math.round(footageProgress * 100)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 transition-all duration-200"
|
||||
style={{ width: `${footageProgress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={footageInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/*"
|
||||
className="hidden"
|
||||
onChange={handleFootageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const settingsSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
|
||||
+46
-64
@@ -1,72 +1,54 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# ── PostgreSQL ──────────────────────────────────────────
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: vfxreview_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: vfxreview
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
vfxreview:
|
||||
image: vfxreview:1.0.1
|
||||
container_name: vfxreview
|
||||
|
||||
# ── MinIO (S3-compatible local storage) ────────────────
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: vfxreview_storage
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
# ── App (production build) ─────────────────────────────
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
platforms:
|
||||
- linux/arm64
|
||||
- linux/amd64
|
||||
container_name: vfxreview_app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/vfxreview
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||
STORAGE_PROVIDER: minio
|
||||
MINIO_ENDPOINT: http://minio:9000
|
||||
MINIO_ACCESS_KEY: minioadmin
|
||||
MINIO_SECRET_KEY: minioadmin
|
||||
MINIO_BUCKET_NAME: vfx-review
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
- app-net
|
||||
- npm_default
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: vfxreview-db
|
||||
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: feedback
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
restart: always
|
||||
|
||||
networks:
|
||||
- app-net
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
app-net:
|
||||
|
||||
npm_default:
|
||||
external: true
|
||||
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "shots" ADD COLUMN "originalFootageKey" TEXT,
|
||||
ADD COLUMN "originalFootageUrl" TEXT;
|
||||
@@ -267,6 +267,8 @@ model Shot {
|
||||
fps Float @default(24)
|
||||
dueDate DateTime?
|
||||
thumbnailUrl String?
|
||||
originalFootageUrl String?
|
||||
originalFootageKey String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -162,6 +162,8 @@ export interface ShotWithDetails {
|
||||
frameEnd: number | null;
|
||||
dueDate: Date | null;
|
||||
thumbnailUrl: string | null;
|
||||
originalFootageUrl: string | null;
|
||||
originalFootageKey: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
artist: {
|
||||
|
||||
Reference in New Issue
Block a user