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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user