Resep Integrasi

Integrasi React Dropzone

Buat area unggah tarik-dan-lepas (drag-and-drop) yang intuitif dengan indikator progres animasi menggunakan react-dropzone dan `@cloudizero/sdk`.

Instalasi

bash
pnpm add react-dropzone @cloudizero/sdk

1. Endpoint Backend (Presign & Complete)

API route sisi server untuk menghasilkan tiket presigned dan memvalidasi penyelesaian tanpa mengekspos API Key rahasia ke browser:

app/api/upload/route.ts
// app/api/upload/route.ts (Next.js App Router API Route)
import { NextResponse } from "next/server";
import { Cloudizero } from "@cloudizero/sdk";

const cz = new Cloudizero({
  apiKey: process.env.CLOUDIZERO_API_KEY!,
});

// POST /api/upload - Generates presigned upload URL or finalizes metadata
export async function POST(req: Request) {
  const body = await req.json();

  if (body.action === "presign") {
    const { fileName, fileSize, mimeType, projectId } = body;
    const ticket = await cz.storage.createPresignedUpload({
      projectId,
      fileName,
      fileSize,
      mimeType,
    });
    return NextResponse.json(ticket);
  }

  if (body.action === "complete") {
    const { fileId, projectId, fileName, mimeType, fileSize, storageKey } = body;
    const recorded = await cz.storage.completeUpload({
      fileId,
      projectId,
      fileName,
      mimeType,
      fileSize,
      storageKey,
    });
    return NextResponse.json(recorded);
  }

  return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}

2. Komponen Dropzone Klien

Komponen klien React Dropzone yang mengunggah biner langsung ke Cloudizero Storage Engine dengan bilah progres waktu-nyata:

components/dropzone-uploader.tsx
"use client";

import { useState } from "react";
import { useDropzone } from "react-dropzone";
import type { PresignedUploadResult, UploadedFile } from "@cloudizero/sdk";

export function DropzoneUploader({ projectId }: { projectId: string }) {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [uploadedUrl, setUploadedUrl] = useState<string | null>(null);

  const onDrop = async (acceptedFiles: File[]) => {
    const file = acceptedFiles[0];
    if (!file) return;

    setUploading(true);
    setProgress(0);
    setUploadedUrl(null);

    try {
      // 1. Dapatkan tiket upload presigned dari backend Anda
      const presignRes = await fetch("/api/upload", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "presign",
          fileName: file.name,
          fileSize: file.size,
          mimeType: file.type || "application/octet-stream",
          projectId,
        }),
      });
      const ticket: PresignedUploadResult = await presignRes.json();

      // 2. Unggah biner langsung dari browser ke Cloudizero Storage Engine
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("PUT", ticket.uploadUrl);
        xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");

        xhr.upload.onprogress = (evt) => {
          if (evt.lengthComputable) {
            setProgress(Math.round((evt.loaded / evt.total) * 100));
          }
        };

        xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error("PUT upload failed")));
        xhr.onerror = () => reject(new Error("Network error during direct upload"));
        xhr.send(file);
      });

      // 3. Rekam metadata dan finalisasi DAM asset di Cloudizero
      const completeRes = await fetch("/api/upload", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "complete",
          fileId: ticket.fileId,
          projectId,
          fileName: ticket.finalFileName || file.name,
          mimeType: file.type || "application/octet-stream",
          fileSize: file.size,
          storageKey: ticket.storageKey,
        }),
      });
      const recorded: UploadedFile = await completeRes.json();
      setUploadedUrl(recorded.url || null);
    } catch (err) {
      console.error("Dropzone upload failed:", err);
    } finally {
      setUploading(false);
    }
  };

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    multiple: false,
  });

  return (
    <div className="space-y-4">
      <div
        {...getRootProps()}
        className={`border-2 border-dashed rounded-2xl p-10 text-center cursor-pointer transition-colors ${
          isDragActive ? "border-blue-500 bg-blue-500/10" : "border-border hover:border-blue-500/40"
        }`}
      >
        <input {...getInputProps()} />
        {uploading ? (
          <div className="space-y-2">
            <p className="text-sm font-medium">Uploading: {progress}%</p>
            <div className="w-48 mx-auto bg-muted h-2 rounded-full overflow-hidden">
              <div className="bg-blue-600 h-full transition-all" style={{ width: `${progress}%` }} />
            </div>
          </div>
        ) : isDragActive ? (
          <p className="text-sm font-medium text-blue-600">Drop files here to upload...</p>
        ) : (
          <p className="text-sm text-muted-foreground">
            Drag & drop media here, or <span className="text-blue-600 font-medium">browse files</span>
          </p>
        )}
      </div>

      {uploadedUrl && (
        <div className="p-4 bg-muted/40 rounded-xl text-sm flex items-center justify-between">
          <span className="text-muted-foreground truncate max-w-xs">{uploadedUrl}</span>
          <a href={uploadedUrl} target="_blank" rel="noreferrer" className="text-blue-600 font-medium shrink-0">
            View on CDN &rarr;
          </a>
        </div>
      )}
    </div>
  );
}