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)) {
|
if (!(file instanceof File)) {
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
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}` };
|
const auth = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ export async function POST(req: NextRequest) {
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
mime_type: file.type || "application/octet-stream",
|
mime_type: file.type || "application/octet-stream",
|
||||||
size_bytes: file.size,
|
size_bytes: file.size,
|
||||||
|
target_folder_id: targetFolderId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const presign = await presignRes.json().catch(() => null);
|
const presign = await presignRes.json().catch(() => null);
|
||||||
|
|||||||
@@ -2,13 +2,29 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 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";
|
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() {
|
export function FileManager() {
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
|
const [folders, setFolders] = useState<FolderItem[]>([]);
|
||||||
|
const [crumbs, setCrumbs] = useState<Crumb[]>([{ id: null, name: "کتابخانه" }]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -18,6 +34,8 @@ export function FileManager() {
|
|||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const folderId = crumbs[crumbs.length - 1].id;
|
||||||
|
|
||||||
const toggleSel = (id: string) =>
|
const toggleSel = (id: string) =>
|
||||||
setSelected((s) => {
|
setSelected((s) => {
|
||||||
const n = new Set(s);
|
const n = new Set(s);
|
||||||
@@ -29,13 +47,18 @@ export function FileManager() {
|
|||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setFiles(await fetchFiles(search, type));
|
const [fl, fd] = await Promise.all([
|
||||||
|
fetchFiles(search, type, folderId),
|
||||||
|
fetchFolders(folderId),
|
||||||
|
]);
|
||||||
|
setFiles(fl);
|
||||||
|
setFolders(fd);
|
||||||
} catch {
|
} catch {
|
||||||
setError("بارگذاری فایلها ناموفق بود");
|
setError("بارگذاری فایلها ناموفق بود");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [search, type]);
|
}, [search, type, folderId]);
|
||||||
|
|
||||||
useEffect(() => { reload(); }, [reload]);
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
@@ -45,6 +68,7 @@ export function FileManager() {
|
|||||||
for (const file of Array.from(list)) {
|
for (const file of Array.from(list)) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
|
if (folderId) fd.append("folder_id", folderId);
|
||||||
const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd });
|
const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const d = await res.json().catch(() => null);
|
const d = await res.json().catch(() => null);
|
||||||
@@ -56,6 +80,24 @@ export function FileManager() {
|
|||||||
reload();
|
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) => {
|
const remove = async (f: FileItem) => {
|
||||||
if (!confirm(`«${f.name}» حذف شود؟`)) return;
|
if (!confirm(`«${f.name}» حذف شود؟`)) return;
|
||||||
const res = await fetch(`/api/admin/resource/files/${f.id}`, { method: "DELETE" });
|
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>
|
<h1 className="text-xl font-semibold text-white">کتابخانه رسانه</h1>
|
||||||
<p className="mt-1 text-sm text-gray-400">آپلود و مدیریت تصاویر، ویدیوها و پروژههای افترافکت. نشانی هر فایل را میتوان در فرمها استفاده کرد.</p>
|
<p className="mt-1 text-sm text-gray-400">آپلود و مدیریت تصاویر، ویدیوها و پروژههای افترافکت. نشانی هر فایل را میتوان در فرمها استفاده کرد.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className={btn} onClick={() => inputRef.current?.click()} disabled={uploading}>
|
<div className="flex gap-2">
|
||||||
{uploading ? "در حال آپلود…" : "+ آپلود فایل"}
|
<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>
|
||||||
|
<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)} />
|
<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>
|
</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 items-center gap-2">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{FILE_TYPE_TABS.map((t) => (
|
{FILE_TYPE_TABS.map((t) => (
|
||||||
@@ -116,10 +179,38 @@ export function FileManager() {
|
|||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }}
|
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 ? (
|
{loading ? (
|
||||||
<p className="py-10 text-center text-sm text-gray-500">در حال بارگذاری…</p>
|
<p className="py-10 text-center text-sm text-gray-500">در حال بارگذاری…</p>
|
||||||
) : files.length === 0 ? (
|
) : 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">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
{files.map((f) => {
|
{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]}`;
|
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch files from the admin gateway with optional search + type filter. */
|
/** Fetch files from the admin gateway with optional search + type + folder filter. */
|
||||||
export async function fetchFiles(search: string, fileType: string, page = 1, pageSize = 60): Promise<FileItem[]> {
|
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) });
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize), page_size: String(pageSize) });
|
||||||
if (search) qs.set("search", search);
|
if (search) qs.set("search", search);
|
||||||
if (fileType) qs.set("file_type", fileType);
|
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);
|
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 : []);
|
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