diff --git a/Dockerfile b/Dockerfile index 3847680..25fb260 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,8 @@ ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000 # V2: browser-facing gateway base (host-exposed port) + tenant for Identity auth ARG NEXT_PUBLIC_API_URL=http://localhost:8088/v1 ARG NEXT_PUBLIC_TENANT_SLUG=flatrender +# Browser-reachable MinIO base for public (user-uploads) object URLs. +ARG NEXT_PUBLIC_MINIO_URL=http://localhost:9000 ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY @@ -40,6 +42,7 @@ ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_TENANT_SLUG=$NEXT_PUBLIC_TENANT_SLUG +ENV NEXT_PUBLIC_MINIO_URL=$NEXT_PUBLIC_MINIO_URL ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index e93c577..eb1bc74 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -276,6 +276,7 @@ services: # V2 gateway: browser-facing base (host port) baked in at build time. NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:${GATEWAY_PORT:-8080}/v1}" NEXT_PUBLIC_TENANT_SLUG: "${NEXT_PUBLIC_TENANT_SLUG:-flatrender}" + NEXT_PUBLIC_MINIO_URL: "${NEXT_PUBLIC_MINIO_URL:-http://localhost:9000}" container_name: fr2-frontend restart: unless-stopped ports: diff --git a/messages/en.json b/messages/en.json index 1012ff6..0ffefcb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -320,7 +320,8 @@ "slides": "Slides", "users": "Users", "plans": "Plans", - "templates": "Templates" + "templates": "Templates", + "media": "Media" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index 6965518..9933a58 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -320,7 +320,8 @@ "slides": "اسلایدها", "users": "کاربران", "plans": "پلن‌ها", - "templates": "قالب‌ها" + "templates": "قالب‌ها", + "media": "رسانه" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/src/app/[locale]/admin/files/page.tsx b/src/app/[locale]/admin/files/page.tsx new file mode 100644 index 0000000..84135ca --- /dev/null +++ b/src/app/[locale]/admin/files/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { FileManager } from "@/components/admin/FileManager"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index 55f387f..bc28c04 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -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") }, diff --git a/src/app/api/admin/files/upload/route.ts b/src/app/api/admin/files/upload/route.ts new file mode 100644 index 0000000..2f65824 --- /dev/null +++ b/src/app/api/admin/files/upload/route.ts @@ -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, + }); +} diff --git a/src/components/admin/AdminResource.tsx b/src/components/admin/AdminResource.tsx index 52fa866..695b2c9 100644 --- a/src/components/admin/AdminResource.tsx +++ b/src/components/admin/AdminResource.tsx @@ -2,10 +2,12 @@ import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { FileUploadField } from "@/components/admin/FileUploadField"; + export interface FieldDef { key: string; label: string; - type?: "text" | "textarea" | "number" | "checkbox" | "select"; + type?: "text" | "textarea" | "number" | "checkbox" | "select" | "image" | "file"; options?: { value: string; label: string }[]; required?: boolean; placeholder?: string; @@ -215,6 +217,12 @@ export function AdminResource({ config }: { config: ResourceConfig }) { setForm({ ...form, [f.key]: e.target.checked })} /> {f.label} + ) : f.type === "image" || f.type === "file" ? ( + setForm({ ...form, [f.key]: url })} + accept={f.type === "image" ? "image/*" : "*/*"} + /> ) : ( = 1024 && i < u.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`; +} + +export function FileManager() { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(null); + const inputRef = useRef(null); + + const reload = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/admin/resource/files", { cache: "no-store" }); + const data = await res.json(); + setFiles(data?.data ?? data?.items ?? (Array.isArray(data) ? data : [])); + } catch { + setError("Failed to load files"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { reload(); }, [reload]); + + const uploadFiles = async (list: FileList) => { + setUploading(true); + setError(null); + for (const file of Array.from(list)) { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd }); + if (!res.ok) { + const d = await res.json().catch(() => null); + setError(d?.error ?? `Failed to upload ${file.name}`); + } + } + setUploading(false); + if (inputRef.current) inputRef.current.value = ""; + reload(); + }; + + const remove = async (f: FileItem) => { + if (!confirm(`Delete ${f.name}?`)) return; + const res = await fetch(`/api/admin/resource/files/${f.id}`, { method: "DELETE" }); + if (res.ok) reload(); else setError("Delete failed"); + }; + + const copy = (url: string) => { + navigator.clipboard?.writeText(url); + setCopied(url); + setTimeout(() => setCopied(null), 1500); + }; + + return ( +
+
+
+

Media Library

+

Upload and manage images & files. Public URLs can be copied into any field.

+
+ + e.target.files && uploadFiles(e.target.files)} /> +
+ + {error &&

{error}

} + +
e.preventDefault()} + onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }} + > + {loading ? ( +

Loading…

+ ) : files.length === 0 ? ( +

No files yet. Drag & drop here or click Upload.

+ ) : ( +
+ {files.map((f) => { + const url = fileUrl(f); + return ( +
+
+ {url && isImage(f) ? ( + // eslint-disable-next-line @next/next/no-img-element + {f.name} + ) : ( + {(f.name.split(".").pop() ?? "file").slice(0, 5)} + )} +
+

{f.name}

+

{humanSize(f.size_bytes)}

+
+ {url && ( + + )} + +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/admin/FileUploadField.tsx b/src/components/admin/FileUploadField.tsx new file mode 100644 index 0000000..5b8652b --- /dev/null +++ b/src/components/admin/FileUploadField.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRef, useState } from "react"; + +/** Upload-or-clear field that replaces a raw URL text input. Stores the public URL. */ +export function FileUploadField({ + value, + onChange, + accept = "image/*", + label, +}: { + value: string; + onChange: (url: string) => void; + accept?: string; + label?: string; +}) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + + const isImage = value && /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(value); + + const upload = async (file: File) => { + setUploading(true); + setError(null); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd }); + const data = await res.json(); + if (!res.ok || !data?.url) throw new Error(data?.error ?? "Upload failed"); + onChange(data.url); + } catch (e) { + setError(e instanceof Error ? e.message : "Upload failed"); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ""; + } + }; + + return ( +
+ {label && } +
+ {value ? ( + isImage ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + {value.split("/").pop()} + + ) + ) : ( + + none + + )} +
+ { + const f = e.target.files?.[0]; + if (f) upload(f); + }} + /> + + {value && ( + + )} +
+
+ {error &&

{error}

} +
+ ); +} diff --git a/src/components/admin/admin-resources.tsx b/src/components/admin/admin-resources.tsx index 78b830e..7d06e93 100644 --- a/src/components/admin/admin-resources.tsx +++ b/src/components/admin/admin-resources.tsx @@ -51,7 +51,7 @@ export const categoriesConfig: ResourceConfig = { { key: "name", label: "Name", required: true }, { key: "slug", label: "Slug", required: true }, { key: "description", label: "Description / content", type: "textarea" }, - { key: "image_url", label: "Image URL" }, + { key: "image_url", label: "Image", type: "image" }, { key: "icon", label: "Icon" }, // SEO { key: "meta_title", label: "SEO · Meta title" }, diff --git a/src/lib/files.ts b/src/lib/files.ts new file mode 100644 index 0000000..34df676 --- /dev/null +++ b/src/lib/files.ts @@ -0,0 +1,20 @@ +/** + * Public base URL for objects in the (public-read) user-uploads MinIO bucket. + * Must be browser-reachable (MinIO is published on :9000). Configure per deployment + * via NEXT_PUBLIC_MINIO_URL; falls back to localhost for local dev. + */ +export const MINIO_PUBLIC_URL = ( + process.env.NEXT_PUBLIC_MINIO_URL ?? "http://localhost:9000" +).replace(/\/$/, ""); + +/** Build a public object URL from a file's bucket + key. */ +export function publicFileUrl(bucket: string, key: string): string { + return `${MINIO_PUBLIC_URL}/${bucket}/${key}`; +} + +export interface UploadedFile { + id: string; + name: string; + url: string; + mime_type?: string; +}