diff --git a/apps/web/src/routes/admin/media/index.tsx b/apps/web/src/routes/admin/media/index.tsx index e66c1c79f8..e7ebe4eb44 100644 --- a/apps/web/src/routes/admin/media/index.tsx +++ b/apps/web/src/routes/admin/media/index.tsx @@ -36,6 +36,15 @@ function MediaLibrary() { const [folderOptions, setFolderOptions] = useState([]); const [moving, setMoving] = useState(false); const [downloading, setDownloading] = useState(false); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + item: MediaItem; + } | null>(null); + const [showRenameDialog, setShowRenameDialog] = useState(false); + const [renameItem, setRenameItem] = useState(null); + const [newName, setNewName] = useState(""); + const [renaming, setRenaming] = useState(false); const fileInputRef = useRef(null); const fetchItems = useCallback(async (path: string) => { @@ -333,6 +342,137 @@ function MediaLibrary() { navigator.clipboard.writeText(text); }; + const handleContextMenu = (e: React.MouseEvent, item: MediaItem) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, item }); + }; + + const closeContextMenu = () => { + setContextMenu(null); + }; + + const handleContextDelete = async (item: MediaItem) => { + closeContextMenu(); + if (!confirm(`Are you sure you want to delete "${item.name}"?`)) return; + + try { + const response = await fetch("/api/admin/media/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paths: [item.path] }), + }); + + const data = await response.json(); + if (data.errors && data.errors.length > 0) { + setError(`Failed to delete: ${data.errors.join(", ")}`); + } + await fetchItems(currentPath); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleContextDownload = async (item: MediaItem) => { + closeContextMenu(); + if (!item.downloadUrl) return; + + try { + const response = await fetch(item.downloadUrl); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = item.name; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (err) { + setError((err as Error).message); + } + }; + + const handleContextCopyPath = (item: MediaItem) => { + closeContextMenu(); + copyToClipboard(item.publicPath); + }; + + const handleContextCopyImage = async (item: MediaItem) => { + closeContextMenu(); + if (!item.downloadUrl) return; + + try { + const response = await fetch(item.downloadUrl); + const blob = await response.blob(); + await navigator.clipboard.write([ + new ClipboardItem({ [blob.type]: blob }), + ]); + } catch (err) { + setError("Failed to copy image to clipboard"); + } + }; + + const openRenameDialog = (item: MediaItem) => { + closeContextMenu(); + setRenameItem(item); + const nameParts = item.name.split("."); + const nameWithoutExt = + nameParts.length > 1 ? nameParts.slice(0, -1).join(".") : item.name; + setNewName(nameWithoutExt); + setShowRenameDialog(true); + }; + + const handleRename = async () => { + if (!renameItem || !newName.trim()) return; + setRenaming(true); + + try { + const ext = renameItem.name.includes(".") + ? "." + renameItem.name.split(".").pop() + : ""; + const newFileName = newName.trim() + ext; + const parentPath = renameItem.path.split("/").slice(0, -1).join("/"); + const toPath = parentPath ? `${parentPath}/${newFileName}` : newFileName; + + const response = await fetch("/api/admin/media/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fromPath: renameItem.path, toPath }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Rename failed"); + } + + setShowRenameDialog(false); + setRenameItem(null); + setNewName(""); + await fetchItems(currentPath); + } catch (err) { + setError((err as Error).message); + } finally { + setRenaming(false); + } + }; + + const openContextMoveDialog = async (item: MediaItem) => { + closeContextMenu(); + setSelectedItems(new Set([item.path])); + await fetchFolderOptions(); + setMoveDestination(""); + setShowMoveDialog(true); + }; + + useEffect(() => { + const handleClickOutside = () => closeContextMenu(); + if (contextMenu) { + document.addEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); + } + }, [contextMenu]); + return (
@@ -512,6 +652,7 @@ function MediaLibrary() { ) : toggleSelection(item.path) } + onContextMenu={(e) => handleContextMenu(e, item)} > {item.type === "dir" ? (
@@ -617,6 +758,183 @@ function MediaLibrary() {
)} + + {contextMenu && ( +
+ {contextMenu.item.type === "file" && ( + <> + + + +
+ + )} + + +
+ +
+ )} + + {showRenameDialog && renameItem && ( +
+
+

+ Rename "{renameItem.name}" +

+
+ +
+ setNewName(e.target.value)} + className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + onKeyDown={(e) => e.key === "Enter" && handleRename()} + autoFocus + /> + {renameItem.name.includes(".") && ( + + .{renameItem.name.split(".").pop()} + + )} +
+
+
+ + +
+
+
+ )}
); }