From 1142c38c626f024a775cd5eacd293c9061934071 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Fri, 5 Jun 2026 12:29:03 +0330 Subject: [PATCH] feat(editor+trimmer): save output to cloud account via V2 File service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /api/files/upload: generic user-scoped Browser→Next→MinIO upload (presign → PUT → confirm), 200MB cap, image+video only, returns public URL - image-editor-export: stageToBlob() + saveStageToCloud(); "Save to my account" button in the Image Editor export popover - Trimmer: "Save to my account" button uploads the trimmed clip blob - i18n: saveToCloud/savingToCloud/savedToCloud/saveToCloudFailed in fa+en (parity 1002/1002) Connects the two client-side editors to V2 storage — output now lands in the user's account instead of only a local download. Co-Authored-By: Claude Opus 4.8 --- messages/en.json | 12 ++- messages/fa.json | 12 ++- src/app/api/files/upload/route.ts | 89 +++++++++++++++++++ .../image-editor/ImageEditorTopBar.tsx | 34 ++++++- .../trimmer/TrimmerExportSection.tsx | 41 ++++++++- src/lib/image-editor-export.ts | 43 +++++++++ 6 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 src/app/api/files/upload/route.ts diff --git a/messages/en.json b/messages/en.json index 190913e..360ae8d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -671,7 +671,11 @@ "trimAndCrop": "Trim & Crop", "loadingEngine": "Loading FFmpeg engine…", "progress": "Progress", - "download": "Download {format}" + "download": "Download {format}", + "saveToCloud": "Save to my account", + "savingToCloud": "Saving…", + "savedToCloud": "Saved to your account", + "saveToCloudFailed": "Could not save" }, "componentsTrimmerTrimmerStrip": { "heading": "Trim", @@ -866,7 +870,11 @@ "quality": "Quality", "download": "Download", "canvasNotReady": "Canvas not ready.", - "exportStarted": "Export started" + "exportStarted": "Export started", + "saveToCloud": "Save to my account", + "savingToCloud": "Saving…", + "savedToCloud": "Saved to your account", + "saveToCloudFailed": "Could not save to your account" }, "componentsImageEditorPanelsAdjustPanel": { "emptyState": "Open an image to use adjustments.", diff --git a/messages/fa.json b/messages/fa.json index d6565ef..fad6776 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -671,7 +671,11 @@ "trimAndCrop": "برش و کراپ", "loadingEngine": "در حال بارگذاری موتور FFmpeg…", "progress": "پیشرفت", - "download": "دانلود {format}" + "download": "دانلود {format}", + "saveToCloud": "ذخیره در حساب", + "savingToCloud": "در حال ذخیره…", + "savedToCloud": "در حساب شما ذخیره شد", + "saveToCloudFailed": "ذخیره ناموفق بود" }, "componentsTrimmerTrimmerStrip": { "heading": "برش", @@ -866,7 +870,11 @@ "quality": "کیفیت", "download": "دانلود", "canvasNotReady": "بوم آماده نیست.", - "exportStarted": "خروجی‌گیری آغاز شد" + "exportStarted": "خروجی‌گیری آغاز شد", + "saveToCloud": "ذخیره در حساب", + "savingToCloud": "در حال ذخیره…", + "savedToCloud": "در حساب شما ذخیره شد", + "saveToCloudFailed": "ذخیره در حساب ناموفق بود" }, "componentsImageEditorPanelsAdjustPanel": { "emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.", diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts new file mode 100644 index 0000000..e57edb7 --- /dev/null +++ b/src/app/api/files/upload/route.ts @@ -0,0 +1,89 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { gatewayUrl } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; +import { MINIO_PUBLIC_URL } from "@/lib/files"; + +export const dynamic = "force-dynamic"; + +// Generous cap for editor/trimmer output (trimmed clips, high-res exports). +const MAX_BYTES = 200 * 1024 * 1024; // 200 MB + +/** + * Generic user-scoped upload: Browser → Next → MinIO (presign → PUT → confirm). + * + * Unlike /api/profile/upload (avatar-only, persists to Identity), this just stores + * the file in the user's `user-uploads` bucket and returns the public URL. Used by + * the Image Editor "Save to cloud" and the Video Trimmer "Save to cloud" actions so + * a user's work lands in their account instead of only a local download. + */ +export async function POST(req: NextRequest) { + const token = await getAccessToken(); + if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const form = await req.formData().catch(() => null); + const file = form?.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + if (file.size > MAX_BYTES) { + return NextResponse.json({ error: "File too large (max 200MB)" }, { status: 413 }); + } + const mime = file.type || "application/octet-stream"; + if (!mime.startsWith("image/") && !mime.startsWith("video/")) { + return NextResponse.json( + { error: "Only image or video files are allowed" }, + { status: 415 } + ); + } + + const auth = { Authorization: `Bearer ${token}` }; + + // 1. presigned PUT URL + const presignRes = await fetch(gatewayUrl("/v1/files/presigned-upload"), { + method: "POST", + cache: "no-store", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ + filename: file.name, + mime_type: mime, + size_bytes: file.size, + }), + }); + const presign = await presignRes.json().catch(() => null); + if (!presignRes.ok || !presign?.upload_url || !presign?.file_id) { + return NextResponse.json( + { error: presign?.error?.message ?? "Could not start upload" }, + { status: presignRes.status || 502 } + ); + } + + // 2. PUT the bytes to MinIO (server-side; reaches minio:9000) + const put = await fetch(presign.upload_url, { + method: "PUT", + headers: { "Content-Type": mime }, + body: Buffer.from(await file.arrayBuffer()), + }); + if (!put.ok) { + return NextResponse.json({ error: "Upload to storage failed" }, { status: 502 }); + } + + // 3. confirm + await fetch(gatewayUrl(`/v1/files/${presign.file_id}/confirm`), { + method: "POST", + cache: "no-store", + headers: auth, + }); + + // 4. resolve the public URL + const detailRes = await fetch(gatewayUrl(`/v1/files/${presign.file_id}`), { + cache: "no-store", + headers: auth, + }); + const detail = await detailRes.json().catch(() => null); + const bucket = detail?.minio_bucket ?? "user-uploads"; + const key = detail?.minio_key; + const url = key ? `${MINIO_PUBLIC_URL}/${bucket}/${key}` : null; + + return NextResponse.json({ id: presign.file_id, name: file.name, mime_type: mime, url }); +} diff --git a/src/components/image-editor/ImageEditorTopBar.tsx b/src/components/image-editor/ImageEditorTopBar.tsx index 5f2224f..755f5de 100644 --- a/src/components/image-editor/ImageEditorTopBar.tsx +++ b/src/components/image-editor/ImageEditorTopBar.tsx @@ -3,13 +3,13 @@ import { useRef, useState } from "react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { Download, FolderOpen, Sparkles } from "lucide-react"; +import { CloudUpload, Download, FolderOpen, Sparkles } from "lucide-react"; import { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator"; import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { toast } from "@/components/ui/use-toast"; -import { downloadStageImage } from "@/lib/image-editor-export"; +import { downloadStageImage, saveStageToCloud } from "@/lib/image-editor-export"; import { getImageEditorStage } from "@/lib/image-editor-stage-ref"; import type { ExportImageFormat } from "@/lib/image-editor-types"; import type { ProjectSaveStatus } from "@/lib/project-save-status"; @@ -32,6 +32,7 @@ export function ImageEditorTopBar({ const t = useTranslations("auto.componentsImageEditorImageEditorTopBar"); const fileRef = useRef(null); const [exportOpen, setExportOpen] = useState(false); + const [savingCloud, setSavingCloud] = useState(false); const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage); const exportFormat = useImageEditorStore((s) => s.exportFormat); @@ -62,6 +63,24 @@ export function ImageEditorTopBar({ setExportOpen(false); }; + const handleSaveToCloud = async () => { + const stage = getImageEditorStage(); + if (!stage) { + toast({ title: t("canvasNotReady") }); + return; + } + setSavingCloud(true); + try { + const url = await saveStageToCloud(stage, exportFormat, exportQuality); + toast({ title: url ? t("savedToCloud") : t("saveToCloudFailed") }); + if (url) setExportOpen(false); + } catch { + toast({ title: t("saveToCloudFailed") }); + } finally { + setSavingCloud(false); + } + }; + return (
@@ -154,8 +173,19 @@ export function ImageEditorTopBar({ className="w-full bg-primary-600 hover:bg-primary-700" onClick={handleExport} > + {t("download")} +
) : null} diff --git a/src/components/trimmer/TrimmerExportSection.tsx b/src/components/trimmer/TrimmerExportSection.tsx index b7a3330..4e11788 100644 --- a/src/components/trimmer/TrimmerExportSection.tsx +++ b/src/components/trimmer/TrimmerExportSection.tsx @@ -1,9 +1,11 @@ "use client"; -import { Download, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { CloudUpload, Download, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/use-toast"; import type { ExportFormat } from "@/lib/trimmer-types"; import { cn } from "@/lib/utils"; @@ -29,6 +31,29 @@ export function TrimmerExportSection({ onProcess, }: TrimmerExportSectionProps) { const t = useTranslations("auto.componentsTrimmerTrimmerExportSection"); + const [savingCloud, setSavingCloud] = useState(false); + + const handleSaveToCloud = async () => { + if (!outputUrl) return; + setSavingCloud(true); + try { + const blob = await fetch(outputUrl).then((r) => r.blob()); + const fd = new FormData(); + const mime = exportFormat === "webm" ? "video/webm" : "video/mp4"; + fd.append( + "file", + new File([blob], `trimmed-${Date.now()}.${exportFormat}`, { type: mime }) + ); + const res = await fetch("/api/files/upload", { method: "POST", body: fd }); + const data = (await res.json().catch(() => null)) as { url?: string } | null; + toast({ title: res.ok && data?.url ? t("savedToCloud") : t("saveToCloudFailed") }); + } catch { + toast({ title: t("saveToCloudFailed") }); + } finally { + setSavingCloud(false); + } + }; + return (

{t("heading")}

@@ -104,6 +129,20 @@ export function TrimmerExportSection({ {t("download", { format: exportFormat.toUpperCase() })} + ) : null}
diff --git a/src/lib/image-editor-export.ts b/src/lib/image-editor-export.ts index 863d27d..85f105d 100644 --- a/src/lib/image-editor-export.ts +++ b/src/lib/image-editor-export.ts @@ -25,3 +25,46 @@ export function downloadStageImage( link.href = dataUrl; link.click(); } + +/** Render the stage to a Blob at 2× pixel ratio for upload. */ +export async function stageToBlob( + stage: Konva.Stage, + format: ExportImageFormat, + quality: number +): Promise { + const mimeType = + format === "png" + ? "image/png" + : format === "jpg" + ? "image/jpeg" + : "image/webp"; + + const dataUrl = stage.toDataURL({ + pixelRatio: 2, + mimeType, + quality: format === "png" ? 1 : quality / 100, + }); + const res = await fetch(dataUrl); + return res.blob(); +} + +/** + * Render the stage and upload it to the user's cloud library (user-uploads bucket). + * Returns the public URL on success, or null on failure. + */ +export async function saveStageToCloud( + stage: Konva.Stage, + format: ExportImageFormat, + quality: number +): Promise { + const blob = await stageToBlob(stage, format, quality); + if (!blob) return null; + + const fd = new FormData(); + fd.append("file", blob, `design-${Date.now()}.${format}`); + + const res = await fetch("/api/files/upload", { method: "POST", body: fd }); + if (!res.ok) return null; + const data = (await res.json().catch(() => null)) as { url?: string } | null; + return data?.url ?? null; +}