@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { validateReviewToken } from "@/lib/review-auth";
|
||||
import { generateHetznerDownloadUrl } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* GET /api/client/[token]/shots/[shotId]/highres/download
|
||||
* Validates the client review token then returns a presigned Hetzner download URL.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string; shotId: string }> }
|
||||
) {
|
||||
const { token, shotId } = await params;
|
||||
|
||||
const result = await validateReviewToken(token, req);
|
||||
if (result.type !== "ok") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
select: { highResKey: true, highResFilename: true, projectId: true },
|
||||
});
|
||||
|
||||
// Ensure the shot belongs to the review session's project
|
||||
if (!shot || shot.projectId !== result.session.projectId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!shot.highResKey || !shot.highResFilename) {
|
||||
return NextResponse.json({ error: "No high-res file available" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = await generateHetznerDownloadUrl(shot.highResKey, shot.highResFilename);
|
||||
return NextResponse.json({ url });
|
||||
}
|
||||
@@ -81,6 +81,15 @@ export async function GET(
|
||||
const serializedVersion = {
|
||||
...version,
|
||||
fileSize: version.fileSize?.toString() ?? null,
|
||||
// Expose only whether a high-res file exists, never the storage key
|
||||
shot: version.shot
|
||||
? {
|
||||
...version.shot,
|
||||
hasHighRes: !!version.shot.highResKey,
|
||||
highResFilename: version.shot.highResFilename ?? null,
|
||||
highResKey: undefined,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return NextResponse.json({ version: serializedVersion, comments });
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { generateHetznerDownloadUrl } from "@/lib/storage";
|
||||
|
||||
/** GET /api/shots/[shotId]/highres/download — returns a short-lived presigned download URL */
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ shotId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { shotId } = await params;
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
select: { highResKey: true, highResFilename: true },
|
||||
});
|
||||
|
||||
if (!shot?.highResKey || !shot?.highResFilename) {
|
||||
return NextResponse.json({ error: "No high-res file available" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = await generateHetznerDownloadUrl(shot.highResKey, shot.highResFilename);
|
||||
return NextResponse.json({ url });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
/** POST /api/shots/[shotId]/highres — upload a high-res file to Hetzner */
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ shotId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { shotId } = await params;
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
select: { id: true, highResKey: true },
|
||||
});
|
||||
if (!shot) {
|
||||
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("video/")) {
|
||||
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove previous high-res file if one exists
|
||||
if (shot.highResKey) {
|
||||
await deleteFromHetzner(shot.highResKey).catch(() => {});
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const { key } = await uploadToHetzner(buffer, file.name, file.type, "highres");
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { highResKey: key, highResFilename: file.name },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, filename: file.name });
|
||||
}
|
||||
|
||||
/** DELETE /api/shots/[shotId]/highres — remove the high-res file */
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ shotId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { shotId } = await params;
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
select: { id: true, highResKey: true },
|
||||
});
|
||||
if (!shot) {
|
||||
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (shot.highResKey) {
|
||||
await deleteFromHetzner(shot.highResKey).catch(() => {});
|
||||
}
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { highResKey: null, highResFilename: null },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Check,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
|
||||
@@ -56,6 +57,8 @@ interface Version {
|
||||
shot?: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
hasHighRes?: boolean;
|
||||
highResFilename?: string | null;
|
||||
project: { id: string; name: string; code: string };
|
||||
} | null;
|
||||
task?: {
|
||||
@@ -108,6 +111,7 @@ export default function ClientReviewPage({
|
||||
const [nextReview, setNextReview] = useState<{ versionId: string; label: string } | null>(null);
|
||||
const [prevReview, setPrevReview] = useState<{ versionId: string; label: string } | null>(null);
|
||||
const [copiedTaskName, setCopiedTaskName] = useState(false);
|
||||
const [downloadingHighRes, setDownloadingHighRes] = useState(false);
|
||||
|
||||
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||
const { toast } = useToast();
|
||||
@@ -200,6 +204,22 @@ export default function ClientReviewPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadHighRes = async () => {
|
||||
if (!version?.shot?.id || !token) return;
|
||||
setDownloadingHighRes(true);
|
||||
try {
|
||||
const res = await fetch(`/api/client/${token}/shots/${version.shot.id}/highres/download`);
|
||||
if (!res.ok) throw new Error("Could not get download link");
|
||||
const { url } = await res.json();
|
||||
// Open the presigned URL — the Content-Disposition header forces a download
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
} catch {
|
||||
toast({ title: "Download failed", variant: "destructive" });
|
||||
} finally {
|
||||
setDownloadingHighRes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitApproval = async () => {
|
||||
if (!approvalDialog.status || !version) return;
|
||||
setSubmittingApproval(true);
|
||||
@@ -364,6 +384,19 @@ export default function ClientReviewPage({
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
{version.shot?.hasHighRes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1 text-sky-400 border-sky-500/30 hover:bg-sky-500/10"
|
||||
disabled={downloadingHighRes}
|
||||
onClick={handleDownloadHighRes}
|
||||
title={version.shot.highResFilename ?? "Download high-res file"}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{downloadingHighRes ? "Getting link…" : "High Res"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Prev / Next navigation */}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { updateShot, deleteShot } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react";
|
||||
import { Upload, X, Film, ImageIcon, Trash2, FileVideo, CheckCircle2 } from "lucide-react";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
|
||||
@@ -55,6 +55,7 @@ interface ShotSettingsTabProps {
|
||||
dueDate: Date | string | null;
|
||||
artistId: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
highResFilename?: string | null;
|
||||
projectId: string;
|
||||
};
|
||||
artists: Artist[];
|
||||
@@ -72,6 +73,12 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
||||
const [clearThumbnail, setClearThumbnail] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// High-res state
|
||||
const [highResFilename, setHighResFilename] = useState<string | null>(shot.highResFilename ?? null);
|
||||
const [highResUploading, setHighResUploading] = useState(false);
|
||||
const [highResRemoving, setHighResRemoving] = useState(false);
|
||||
const highResInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const formatDate = (d: Date | string | null) => {
|
||||
if (!d) return "";
|
||||
return new Date(d).toISOString().split("T")[0];
|
||||
@@ -97,6 +104,42 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
||||
},
|
||||
});
|
||||
|
||||
const handleHighResChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setHighResUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch(`/api/shots/${shot.id}/highres`, { method: "POST", body: fd });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Upload failed");
|
||||
}
|
||||
setHighResFilename(file.name);
|
||||
toast({ title: "High-res file uploaded" });
|
||||
} catch (err) {
|
||||
toast({ title: "Upload failed", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setHighResUploading(false);
|
||||
if (highResInputRef.current) highResInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleHighResRemove = async () => {
|
||||
setHighResRemoving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/shots/${shot.id}/highres`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("Remove failed");
|
||||
setHighResFilename(null);
|
||||
toast({ title: "High-res file removed" });
|
||||
} catch (err) {
|
||||
toast({ title: "Failed to remove", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setHighResRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -319,9 +362,61 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Saving…" : "Save Changes"}
|
||||
{isSaving ? "Saving\u2026" : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* High Res File */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<FileVideo className="h-4 w-4 text-amber-500" />
|
||||
High Res File
|
||||
</div>
|
||||
<Separator />
|
||||
<p className="text-xs text-zinc-500">Upload the full-resolution MOV deliverable. Stored in Hetzner Object Storage. Clients can download it from the review player.</p>
|
||||
|
||||
{highResFilename ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-800/50 px-4 py-3">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0" />
|
||||
<span className="flex-1 truncate font-mono text-sm text-zinc-200" title={highResFilename}>
|
||||
{highResFilename}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={highResRemoving}
|
||||
onClick={handleHighResRemove}
|
||||
className="shrink-0 text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{highResRemoving ? "Removing\u2026" : "Remove"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => highResInputRef.current?.click()}
|
||||
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => !highResUploading && highResInputRef.current?.click()}
|
||||
className="flex cursor-pointer items-center justify-center gap-2 rounded-lg border-2 border-dashed border-zinc-700 px-4 py-6 text-sm text-zinc-500 transition-colors hover:border-amber-500/50 hover:text-zinc-400"
|
||||
>
|
||||
{highResUploading ? (
|
||||
<><div className="h-4 w-4 animate-spin rounded-full border-2 border-amber-500 border-t-transparent" /><span>Uploading\u2026</span></>
|
||||
) : (
|
||||
<><Upload className="h-4 w-4" /><span>Upload high-res MOV</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={highResInputRef}
|
||||
type="file"
|
||||
accept="video/*,.mov"
|
||||
className="hidden"
|
||||
onChange={handleHighResChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-red-500">
|
||||
|
||||
@@ -219,3 +219,71 @@ function getPublicUrl(key: string, provider: StorageProvider): string {
|
||||
// Fallback
|
||||
return `/${key}`;
|
||||
}
|
||||
|
||||
// ── Hetzner Object Storage (dedicated high-res bucket) ───────────────────────
|
||||
|
||||
function buildHetznerClient(): S3Client {
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: process.env.HETZNER_ENDPOINT!,
|
||||
credentials: {
|
||||
accessKeyId: process.env.HETZNER_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.HETZNER_SECRET_KEY!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getHetznerBucket(): string {
|
||||
return process.env.HETZNER_BUCKET_NAME ?? "vfx-review";
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a buffer directly to the Hetzner bucket (always uses Hetzner credentials,
|
||||
* regardless of the global STORAGE_PROVIDER setting).
|
||||
*/
|
||||
export async function uploadToHetzner(
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
folder: string = "highres"
|
||||
): Promise<{ key: string }> {
|
||||
const key = `${folder}/${randomUUID()}-${fileName}`;
|
||||
const client = buildHetznerClient();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: getHetznerBucket(),
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
return { key };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned download URL from Hetzner that forces a file download
|
||||
* with the original filename preserved via Content-Disposition.
|
||||
*/
|
||||
export async function generateHetznerDownloadUrl(
|
||||
key: string,
|
||||
originalFilename: string,
|
||||
expiresIn: number = 3600
|
||||
): Promise<string> {
|
||||
const client = buildHetznerClient();
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: getHetznerBucket(),
|
||||
Key: key,
|
||||
ResponseContentDisposition: `attachment; filename="${originalFilename}"`,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from the Hetzner bucket by key.
|
||||
*/
|
||||
export async function deleteFromHetzner(key: string): Promise<void> {
|
||||
const client = buildHetznerClient();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({ Bucket: getHetznerBucket(), Key: key })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AddColumn highResKey and highResFilename to shots table
|
||||
ALTER TABLE "shots" ADD COLUMN "highResKey" TEXT;
|
||||
ALTER TABLE "shots" ADD COLUMN "highResFilename" TEXT;
|
||||
@@ -306,6 +306,9 @@ model Shot {
|
||||
// Sequence / picture lock timecodes
|
||||
seqTimecodeStart String?
|
||||
seqTimecodeEnd String?
|
||||
// High-res deliverable (stored in Hetzner Object Storage)
|
||||
highResKey String?
|
||||
highResFilename String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
Reference in New Issue
Block a user