@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user