diff --git a/src/app/api/admin/files/upload/route.ts b/src/app/api/admin/files/upload/route.ts index 2f65824..bd90571 100644 --- a/src/app/api/admin/files/upload/route.ts +++ b/src/app/api/admin/files/upload/route.ts @@ -29,6 +29,10 @@ export async function POST(req: NextRequest) { if (!(file instanceof File)) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } + // Optional: drop the upload into a specific media-library folder. + const folderId = form?.get("folder_id"); + const targetFolderId = + typeof folderId === "string" && folderId ? folderId : undefined; const auth = { Authorization: `Bearer ${token}` }; @@ -41,6 +45,7 @@ export async function POST(req: NextRequest) { filename: file.name, mime_type: file.type || "application/octet-stream", size_bytes: file.size, + target_folder_id: targetFolderId, }), }); const presign = await presignRes.json().catch(() => null); diff --git a/src/components/admin/FileManager.tsx b/src/components/admin/FileManager.tsx index 1d5219b..ca533ec 100644 --- a/src/components/admin/FileManager.tsx +++ b/src/components/admin/FileManager.tsx @@ -2,13 +2,29 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { FILE_TYPE_TABS, fetchFiles, fileUrl, humanSize, isImage, isVideo, type FileItem } from "@/lib/admin-files"; +import { + FILE_TYPE_TABS, + fetchFiles, + fetchFolders, + createFolder, + deleteFolder, + fileUrl, + humanSize, + isImage, + isVideo, + type FileItem, + type FolderItem, +} from "@/lib/admin-files"; const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]"; const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50"; +interface Crumb { id: string | null; name: string } + export function FileManager() { const [files, setFiles] = useState([]); + const [folders, setFolders] = useState([]); + const [crumbs, setCrumbs] = useState([{ id: null, name: "کتابخانه" }]); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); @@ -18,6 +34,8 @@ export function FileManager() { const [selected, setSelected] = useState>(new Set()); const inputRef = useRef(null); + const folderId = crumbs[crumbs.length - 1].id; + const toggleSel = (id: string) => setSelected((s) => { const n = new Set(s); @@ -29,13 +47,18 @@ export function FileManager() { const reload = useCallback(async () => { setLoading(true); try { - setFiles(await fetchFiles(search, type)); + const [fl, fd] = await Promise.all([ + fetchFiles(search, type, folderId), + fetchFolders(folderId), + ]); + setFiles(fl); + setFolders(fd); } catch { setError("بارگذاری فایل‌ها ناموفق بود"); } finally { setLoading(false); } - }, [search, type]); + }, [search, type, folderId]); useEffect(() => { reload(); }, [reload]); @@ -45,6 +68,7 @@ export function FileManager() { for (const file of Array.from(list)) { const fd = new FormData(); fd.append("file", file); + if (folderId) fd.append("folder_id", folderId); const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd }); if (!res.ok) { const d = await res.json().catch(() => null); @@ -56,6 +80,24 @@ export function FileManager() { reload(); }; + const enterFolder = (f: FolderItem) => + setCrumbs((c) => [...c, { id: f.id, name: f.name }]); + + const goToCrumb = (idx: number) => setCrumbs((c) => c.slice(0, idx + 1)); + + const newFolder = async () => { + const name = prompt("نام پوشهٔ جدید؟")?.trim(); + if (!name) return; + if (await createFolder(name, folderId)) reload(); + else setError("ساخت پوشه ناموفق بود"); + }; + + const removeFolder = async (f: FolderItem) => { + if (!confirm(`پوشهٔ «${f.name}» حذف شود؟ (فایل‌های داخل آن حذف نمی‌شوند)`)) return; + if (await deleteFolder(f.id)) reload(); + else setError("حذف پوشه ناموفق بود"); + }; + const remove = async (f: FileItem) => { if (!confirm(`«${f.name}» حذف شود؟`)) return; const res = await fetch(`/api/admin/resource/files/${f.id}`, { method: "DELETE" }); @@ -83,12 +125,33 @@ export function FileManager() {

کتابخانه رسانه

آپلود و مدیریت تصاویر، ویدیوها و پروژه‌های افترافکت. نشانی هر فایل را می‌توان در فرم‌ها استفاده کرد.

- +
+ + +
e.target.files && uploadFiles(e.target.files)} /> + {/* Breadcrumb */} +
+ {crumbs.map((c, i) => ( + + {i > 0 && /} + + + ))} +
+
{FILE_TYPE_TABS.map((t) => ( @@ -116,10 +179,38 @@ export function FileManager() { onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }} > + {/* Folders (hidden while searching, since search spans the whole library) */} + {!search && folders.length > 0 && ( +
+ {folders.map((fd) => ( +
enterFolder(fd)} + onClick={() => enterFolder(fd)} + > + 📁 +
+

{fd.name}

+

{(fd.file_count ?? 0).toLocaleString("fa-IR")} فایل

+
+ +
+ ))} +
+ )} + {loading ? (

در حال بارگذاری…

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

فایلی یافت نشد. فایل را اینجا بکشید و رها کنید یا روی آپلود بزنید.

+

+ {folders.length > 0 ? "این پوشه فایلی ندارد." : "فایلی یافت نشد. فایل را اینجا بکشید و رها کنید یا روی آپلود بزنید."} +

) : (
{files.map((f) => { diff --git a/src/lib/admin-files.ts b/src/lib/admin-files.ts index fac7757..7f2d8be 100644 --- a/src/lib/admin-files.ts +++ b/src/lib/admin-files.ts @@ -48,11 +48,54 @@ export function humanSize(n?: number): string { return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`; } -/** Fetch files from the admin gateway with optional search + type filter. */ -export async function fetchFiles(search: string, fileType: string, page = 1, pageSize = 60): Promise { +/** Fetch files from the admin gateway with optional search + type + folder filter. */ +export async function fetchFiles( + search: string, + fileType: string, + folderId: string | null = null, + page = 1, + pageSize = 60 +): Promise { const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize), page_size: String(pageSize) }); if (search) qs.set("search", search); if (fileType) qs.set("file_type", fileType); + if (folderId) qs.set("folder_id", folderId); const r = await fetch(`/api/admin/resource/files?${qs.toString()}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null); return r?.items ?? r?.data ?? (Array.isArray(r) ? r : []); } + +// ── Folders ─────────────────────────────────────────────────────────────────── + +export interface FolderItem { + id: string; + name: string; + folder_type?: string; + parent_folder_id?: string | null; + file_count?: number; + total_size_bytes?: number; +} + +/** List folders under a parent (null = root). */ +export async function fetchFolders(parentId: string | null = null): Promise { + const qs = parentId ? `?parent_id=${parentId}` : ""; + const r = await fetch(`/api/admin/resource/folders${qs}`, { cache: "no-store" }) + .then((x) => x.json()) + .catch(() => null); + return Array.isArray(r) ? r : (r?.items ?? r?.data ?? []); +} + +/** Create a folder (optionally nested under a parent). */ +export async function createFolder(name: string, parentId: string | null = null): Promise { + const res = await fetch(`/api/admin/resource/folders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, parent_folder_id: parentId }), + }); + return res.ok; +} + +/** Delete a folder by id. */ +export async function deleteFolder(id: string): Promise { + const res = await fetch(`/api/admin/resource/folders/${id}`, { method: "DELETE" }); + return res.ok; +}