feat(admin/media): folders in the media library

- admin-files: fetchFolders / createFolder / deleteFolder + FolderItem; fetchFiles
  takes a folderId filter
- admin files upload route forwards target_folder_id so uploads land in the open folder
- FileManager: breadcrumb navigation, folder cards (open / delete), "+ new folder",
  folder-scoped file listing + upload. Folders hidden while searching (search spans all)

Uses the file-svc folder API (GET/POST/DELETE /v1/folders, folder_id list filter)
that already existed but had no UI. "Pick from library" was already wired via
FilePicker in FileUploadField.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 12:34:56 +03:30
parent 1142c38c62
commit 2918b7acbf
3 changed files with 148 additions and 9 deletions
+5
View File
@@ -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);
+98 -7
View File
@@ -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<FileItem[]>([]);
const [folders, setFolders] = useState<FolderItem[]>([]);
const [crumbs, setCrumbs] = useState<Crumb[]>([{ id: null, name: "کتابخانه" }]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -18,6 +34,8 @@ export function FileManager() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(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() {
<h1 className="text-xl font-semibold text-white">کتابخانه رسانه</h1>
<p className="mt-1 text-sm text-gray-400">آپلود و مدیریت تصاویر، ویدیوها و پروژههای افترافکت. نشانی هر فایل را میتوان در فرمها استفاده کرد.</p>
</div>
<button className={btn} onClick={() => inputRef.current?.click()} disabled={uploading}>
{uploading ? "در حال آپلود…" : "+ آپلود فایل"}
</button>
<div className="flex gap-2">
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs font-medium text-gray-200 hover:bg-[#161a2e]" onClick={newFolder}>
+ پوشهٔ جدید
</button>
<button className={btn} onClick={() => inputRef.current?.click()} disabled={uploading}>
{uploading ? "در حال آپلود…" : "+ آپلود فایل"}
</button>
</div>
<input ref={inputRef} type="file" multiple className="hidden" accept="image/*,video/*,audio/*,.aep,.aepx,.zip" onChange={(e) => e.target.files && uploadFiles(e.target.files)} />
</div>
{/* Breadcrumb */}
<div className="flex flex-wrap items-center gap-1 text-xs text-gray-400">
{crumbs.map((c, i) => (
<span key={`${c.id ?? "root"}-${i}`} className="flex items-center gap-1">
{i > 0 && <span className="text-gray-600">/</span>}
<button
className={i === crumbs.length - 1 ? "text-gray-200" : "text-indigo-300 hover:underline"}
onClick={() => goToCrumb(i)}
disabled={i === crumbs.length - 1}
>
{c.name}
</button>
</span>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap gap-1">
{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 && (
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{folders.map((fd) => (
<div
key={fd.id}
className="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-[#262b40] bg-[#0c0e1a] p-3 hover:border-indigo-500"
onDoubleClick={() => enterFolder(fd)}
onClick={() => enterFolder(fd)}
>
<span className="text-lg" aria-hidden>📁</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-gray-200" title={fd.name}>{fd.name}</p>
<p className="text-[10px] text-gray-600">{(fd.file_count ?? 0).toLocaleString("fa-IR")} فایل</p>
</div>
<button
className="absolute end-1.5 top-1.5 rounded border border-red-500/30 px-1 py-0.5 text-[10px] text-red-300 opacity-0 hover:bg-red-500/10 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); removeFolder(fd); }}
>
حذف
</button>
</div>
))}
</div>
)}
{loading ? (
<p className="py-10 text-center text-sm text-gray-500">در حال بارگذاری</p>
) : files.length === 0 ? (
<p className="py-10 text-center text-sm text-gray-500">فایلی یافت نشد. فایل را اینجا بکشید و رها کنید یا روی آپلود بزنید.</p>
<p className="py-10 text-center text-sm text-gray-500">
{folders.length > 0 ? "این پوشه فایلی ندارد." : "فایلی یافت نشد. فایل را اینجا بکشید و رها کنید یا روی آپلود بزنید."}
</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{files.map((f) => {
+45 -2
View File
@@ -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<FileItem[]> {
/** 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<FileItem[]> {
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<FolderItem[]> {
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<boolean> {
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<boolean> {
const res = await fetch(`/api/admin/resource/folders/${id}`, { method: "DELETE" });
return res.ok;
}