diff --git a/apps/frontend/src/components/ui/servers/FileItem.vue b/apps/frontend/src/components/ui/servers/FileItem.vue index 78ca6100c..06db4103e 100644 --- a/apps/frontend/src/components/ui/servers/FileItem.vue +++ b/apps/frontend/src/components/ui/servers/FileItem.vue @@ -2,40 +2,61 @@
  • -
    +
    -
    +
    {{ name }} - + {{ name }} + + {{ subText }}
    - -
    - {{ - formattedDate - }} +
    + + + {{ formattedModifiedDate }} + - - - - + + + +
    @@ -54,6 +75,7 @@ import { RightArrowIcon, } from "@modrinth/assets"; import { computed, shallowRef, ref } from "vue"; +import { renderToString } from "@vue/server-renderer"; import { useRouter, useRoute } from "vue-router"; import { UiServersIconsCogFolderIcon, @@ -70,12 +92,27 @@ interface FileItemProps { size?: number; count?: number; modified: number; + created: number; path: string; } const props = defineProps(); -const emit = defineEmits(["rename", "download", "delete", "move", "edit", "contextmenu"]); +const emit = defineEmits<{ + (e: "rename", item: { name: string; type: string; path: string }): void; + (e: "move", item: { name: string; type: string; path: string }): void; + ( + e: "moveDirectTo", + item: { name: string; type: string; path: string; destination: string }, + ): void; + (e: "download", item: { name: string; type: string; path: string }): void; + (e: "delete", item: { name: string; type: string; path: string }): void; + (e: "edit", item: { name: string; type: string; path: string }): void; + (e: "contextmenu", x: number, y: number): void; +}>(); + +const isDragOver = ref(false); +const isDragging = ref(false); const codeExtensions = Object.freeze([ "json", @@ -114,6 +151,7 @@ const router = useRouter(); const containerClasses = computed(() => [ "group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised", isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "", + isDragOver.value ? "bg-brand-highlight" : "", ]); const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || ""); @@ -161,7 +199,7 @@ const subText = computed(() => { return formattedSize.value; }); -const formattedDate = computed(() => { +const formattedModifiedDate = computed(() => { const date = new Date(props.modified * 1000); return `${date.toLocaleDateString("en-US", { month: "2-digit", @@ -174,6 +212,19 @@ const formattedDate = computed(() => { })}`; }); +const formattedCreationDate = computed(() => { + const date = new Date(props.created * 1000); + return `${date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "2-digit", + })}, ${date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "numeric", + hour12: true, + })}`; +}); + const isEditableFile = computed(() => { if (props.type === "file") { const ext = fileExtension.value; @@ -226,4 +277,121 @@ const selectItem = () => { isNavigating.value = false; }, 500); }; + +const getDragIcon = async () => { + let iconToUse; + + if (props.type === "directory") { + if (props.name === "config") { + iconToUse = UiServersIconsCogFolderIcon; + } else if (props.name === "world") { + iconToUse = UiServersIconsEarthIcon; + } else if (props.name === "resourcepacks") { + iconToUse = PaletteIcon; + } else { + iconToUse = FolderOpenIcon; + } + } else { + const ext = fileExtension.value; + if (codeExtensions.includes(ext)) { + iconToUse = UiServersIconsCodeFileIcon; + } else if (textExtensions.includes(ext)) { + iconToUse = UiServersIconsTextFileIcon; + } else if (imageExtensions.includes(ext)) { + iconToUse = UiServersIconsImageFileIcon; + } else { + iconToUse = FileIcon; + } + } + + return await renderToString(h(iconToUse)); +}; + +const handleDragStart = async (event: DragEvent) => { + if (!event.dataTransfer) return; + isDragging.value = true; + + const dragGhost = document.createElement("div"); + dragGhost.className = + "fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none"; + + const iconContainer = document.createElement("div"); + iconContainer.className = "flex size-6 items-center justify-center"; + + const icon = document.createElement("div"); + icon.className = "size-4"; + icon.innerHTML = await getDragIcon(); + iconContainer.appendChild(icon); + + const nameSpan = document.createElement("span"); + nameSpan.className = "font-bold truncate text-contrast"; + nameSpan.textContent = props.name; + + dragGhost.appendChild(iconContainer); + dragGhost.appendChild(nameSpan); + document.body.appendChild(dragGhost); + + event.dataTransfer.setDragImage(dragGhost, 0, 0); + + requestAnimationFrame(() => { + document.body.removeChild(dragGhost); + }); + + event.dataTransfer.setData( + "application/pyro-file-move", + JSON.stringify({ + name: props.name, + type: props.type, + path: props.path, + }), + ); + event.dataTransfer.effectAllowed = "move"; +}; + +const isChildPath = (parentPath: string, childPath: string) => { + return childPath.startsWith(parentPath + "/"); +}; + +const handleDragEnd = () => { + isDragging.value = false; +}; + +const handleDragEnter = () => { + if (props.type !== "directory") return; + isDragOver.value = true; +}; + +const handleDragOver = (event: DragEvent) => { + if (props.type !== "directory" || !event.dataTransfer) return; + event.dataTransfer.dropEffect = "move"; +}; + +const handleDragLeave = () => { + isDragOver.value = false; +}; + +const handleDrop = (event: DragEvent) => { + isDragOver.value = false; + if (props.type !== "directory" || !event.dataTransfer) return; + + try { + const dragData = JSON.parse(event.dataTransfer.getData("application/pyro-file-move")); + + if (dragData.path === props.path) return; + + if (dragData.type === "directory" && isChildPath(dragData.path, props.path)) { + console.error("Cannot move a folder into its own subfolder"); + return; + } + + emit("moveDirectTo", { + name: dragData.name, + type: dragData.type, + path: dragData.path, + destination: props.path, + }); + } catch (error) { + console.error("Error handling file drop:", error); + } +}; diff --git a/apps/frontend/src/components/ui/servers/FileVirtualList.vue b/apps/frontend/src/components/ui/servers/FileVirtualList.vue index fe470cbef..56125d76b 100644 --- a/apps/frontend/src/components/ui/servers/FileVirtualList.vue +++ b/apps/frontend/src/components/ui/servers/FileVirtualList.vue @@ -32,6 +32,7 @@ @rename="$emit('rename', item)" @download="$emit('download', item)" @move="$emit('move', item)" + @move-direct-to="$emit('moveDirectTo', $event)" @edit="$emit('edit', item)" @contextmenu="(x, y) => $emit('contextmenu', item, x, y)" /> @@ -55,6 +56,7 @@ const emit = defineEmits<{ (e: "edit", item: any): void; (e: "contextmenu", item: any, x: number, y: number): void; (e: "loadMore"): void; + (e: "moveDirectTo", item: any): void; }>(); const ITEM_HEIGHT = 61; diff --git a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue b/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue index 3e3ed53a5..5cf62b213 100644 --- a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue +++ b/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue @@ -80,6 +80,7 @@ :options="[ { id: 'normal', action: () => $emit('sort', 'default') }, { id: 'modified', action: () => $emit('sort', 'modified') }, + { id: 'created', action: () => $emit('sort', 'created') }, { id: 'filesOnly', action: () => $emit('sort', 'filesOnly') }, { id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') }, ]" @@ -91,11 +92,12 @@