High res uploads
Deploy / deploy (push) Successful in 3m16s

This commit is contained in:
twotalesanimation
2026-06-25 09:56:41 +02:00
parent d2e686335f
commit b683976cc6
9 changed files with 364 additions and 2 deletions
@@ -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 });
}
+85
View File
@@ -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 */}