Skip to content
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 318 additions & 0 deletions apps/web/src/routes/admin/media/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ function MediaLibrary() {
const [folderOptions, setFolderOptions] = useState<FolderOption[]>([]);
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<MediaItem | null>(null);
const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);

const fetchItems = useCallback(async (path: string) => {
Expand Down Expand Up @@ -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 (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-6">
Expand Down Expand Up @@ -512,6 +652,7 @@ function MediaLibrary() {
)
: toggleSelection(item.path)
}
onContextMenu={(e) => handleContextMenu(e, item)}
>
{item.type === "dir" ? (
<div className="aspect-square flex items-center justify-center bg-neutral-100 rounded">
Expand Down Expand Up @@ -617,6 +758,183 @@ function MediaLibrary() {
</div>
</div>
)}

{contextMenu && (
<div
className="fixed bg-white rounded-lg shadow-xl border border-neutral-200 py-1 z-50 min-w-[160px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.item.type === "file" && (
<>
<button
onClick={() => handleContextDownload(contextMenu.item)}
className="w-full px-4 py-2 text-sm text-left text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download
</button>
<button
onClick={() => handleContextCopyPath(contextMenu.item)}
className="w-full px-4 py-2 text-sm text-left text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
Copy Path
</button>
<button
onClick={() => handleContextCopyImage(contextMenu.item)}
className="w-full px-4 py-2 text-sm text-left text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Copy as PNG
</button>
<div className="border-t border-neutral-200 my-1" />
</>
)}
<button
onClick={() => openRenameDialog(contextMenu.item)}
className="w-full px-4 py-2 text-sm text-left text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Rename
</button>
<button
onClick={() => openContextMoveDialog(contextMenu.item)}
className="w-full px-4 py-2 text-sm text-left text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
Move
</button>
<div className="border-t border-neutral-200 my-1" />
<button
onClick={() => handleContextDelete(contextMenu.item)}
className="w-full px-4 py-2 text-sm text-left text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Delete
</button>
</div>
)}

{showRenameDialog && renameItem && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
Rename "{renameItem.name}"
</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-neutral-700 mb-1">
New name
</label>
<div className="flex items-center gap-1">
<input
type="text"
value={newName}
onChange={(e) => 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(".") && (
<span className="text-neutral-500">
.{renameItem.name.split(".").pop()}
</span>
)}
</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => {
setShowRenameDialog(false);
setRenameItem(null);
setNewName("");
}}
className="px-4 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-md hover:bg-neutral-50"
>
Cancel
</button>
<button
onClick={handleRename}
disabled={renaming || !newName.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{renaming ? "Renaming..." : "Rename"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
Expand Down