feat(admin): media library + upload component (replace URL fields)

- /admin/files Media Library: drag-drop multi-upload, thumbnails, copy-URL, delete
- FileUploadField replaces raw URL inputs; new "image" field type in AdminResource;
  wired into category image
- upload proxy /api/admin/files/upload: browser → Next → presigned PUT (server-side,
  reaches minio:9000) → confirm → returns public URL
- user-uploads bucket is public-read; public base via NEXT_PUBLIC_MINIO_URL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 14:55:52 +03:30
parent cf5dd4f195
commit 163f0c9ec3
12 changed files with 368 additions and 4 deletions
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { FileManager } from "@/components/admin/FileManager";
export default function Page() {
return <FileManager />;
}
+1
View File
@@ -22,6 +22,7 @@ export default async function AdminLayout({
{ href: "/admin/fonts", label: t("fonts") },
{ href: "/admin/blogs", label: t("blogs") },
{ href: "/admin/slides", label: t("slides") },
{ href: "/admin/files", label: t("media") },
{ href: "/admin/ai", label: t("aiContent") },
{ href: "/admin/users", label: t("users") },
{ href: "/admin/plans", label: t("plans") },
+87
View File
@@ -0,0 +1,87 @@
import { type NextRequest, NextResponse } from "next/server";
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
import { decodeJwt } from "@/lib/auth/jwt";
import { MINIO_PUBLIC_URL } from "@/lib/files";
export const dynamic = "force-dynamic";
/**
* Browser → Next → MinIO upload proxy. The browser can't reach the presigned MinIO
* host (minio:9000) directly, but the Next server (in the docker network) can. So:
* 1. ask file-svc for a presigned PUT URL
* 2. PUT the bytes server-side
* 3. confirm the upload
* 4. return the public object URL (user-uploads bucket is public-read)
*/
export async function POST(req: NextRequest) {
const token = await getAccessToken();
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const claims = decodeJwt(token);
const isAdmin =
String(claims?.is_admin) === "true" || claims?.is_admin === true ||
String(claims?.is_tenant_admin) === "true";
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
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 });
}
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: file.type || "application/octet-stream",
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": file.type || "application/octet-stream" },
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. fetch the record to get bucket/key → build 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: file.type,
url,
});
}