This commit is contained in:
@@ -23,3 +23,7 @@ Dockerfile*
|
|||||||
.github
|
.github
|
||||||
migrations
|
migrations
|
||||||
README.md
|
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(),
|
dueDate: z.string().optional().nullable(),
|
||||||
artistId: z.string().cuid().optional().nullable().or(z.literal("")),
|
artistId: z.string().cuid().optional().nullable().or(z.literal("")),
|
||||||
thumbnailUrl: z.string().optional().nullable(),
|
thumbnailUrl: z.string().optional().nullable(),
|
||||||
|
originalFootageUrl: z.string().optional().nullable(),
|
||||||
|
originalFootageKey: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function updateShot(data: z.infer<typeof updateShotSchema>) {
|
export async function updateShot(data: z.infer<typeof updateShotSchema>) {
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Settings,
|
Settings,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
|
Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ShotWithDetails } from "@/types";
|
import type { ShotWithDetails } from "@/types";
|
||||||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||||
|
import { FootageViewer } from "@/components/shots/FootageViewer";
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<
|
const STATUS_CONFIG: Record<
|
||||||
string,
|
string,
|
||||||
@@ -51,7 +53,7 @@ export default function ShotDetailPage() {
|
|||||||
const [tasks, setTasks] = useState<any[]>([]);
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
const [artists, setArtists] = useState<any[]>([]);
|
const [artists, setArtists] = useState<any[]>([]);
|
||||||
const [canManage, setCanManage] = useState(false);
|
const [canManage, setCanManage] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"tasks" | "settings">("tasks");
|
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
|
||||||
|
|
||||||
const fetchShot = async () => {
|
const fetchShot = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -190,6 +192,18 @@ export default function ShotDetailPage() {
|
|||||||
<ListTodo className="h-4 w-4" />
|
<ListTodo className="h-4 w-4" />
|
||||||
Tasks
|
Tasks
|
||||||
</button>
|
</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 && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("settings")}
|
onClick={() => setActiveTab("settings")}
|
||||||
@@ -217,6 +231,14 @@ export default function ShotDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "footage" && (
|
||||||
|
<FootageViewer
|
||||||
|
shot={shot}
|
||||||
|
canManage={canManage}
|
||||||
|
onSaved={fetchShot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "settings" && canManage && (
|
{activeTab === "settings" && canManage && (
|
||||||
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
|
<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";
|
} from "@/components/ui/select";
|
||||||
import { updateShot } from "@/actions/shots";
|
import { updateShot } from "@/actions/shots";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
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({
|
const settingsSchema = z.object({
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
|||||||
+46
-64
@@ -1,72 +1,54 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ── PostgreSQL ──────────────────────────────────────────
|
vfxreview:
|
||||||
postgres:
|
image: vfxreview:1.0.1
|
||||||
image: postgres:16-alpine
|
container_name: vfxreview
|
||||||
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
|
|
||||||
|
|
||||||
# ── MinIO (S3-compatible local storage) ────────────────
|
|
||||||
minio:
|
|
||||||
image: minio/minio:latest
|
|
||||||
container_name: vfxreview_storage
|
|
||||||
restart: unless-stopped
|
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) ─────────────────────────────
|
env_file:
|
||||||
app:
|
- .env
|
||||||
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
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./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:
|
volumes:
|
||||||
postgres_data:
|
- pgdata:/var/lib/postgresql/data
|
||||||
minio_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)
|
fps Float @default(24)
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
thumbnailUrl String?
|
thumbnailUrl String?
|
||||||
|
originalFootageUrl String?
|
||||||
|
originalFootageKey String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ export interface ShotWithDetails {
|
|||||||
frameEnd: number | null;
|
frameEnd: number | null;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
|
originalFootageUrl: string | null;
|
||||||
|
originalFootageKey: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
artist: {
|
artist: {
|
||||||
|
|||||||
Reference in New Issue
Block a user