From fee8d6c34e1e71e7d1dd88fb7bcab397badd1661 Mon Sep 17 00:00:00 2001 From: Evan Song <52982404+ferothefox@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:13:31 -0700 Subject: [PATCH] Files UX Sprint (#3019) * chore: dedupe lockfile Signed-off-by: Evan Song * fix: incorrect spacing between editing and browsing state Signed-off-by: Evan Song * chore: improve files image viewer toolbar Signed-off-by: Evan Song * chore: image viewer cursor affordance Signed-off-by: Evan Song * chore: clean imports Signed-off-by: Evan Song * chore: add tooltips Signed-off-by: Evan Song * chore: use black background Signed-off-by: Evan Song * feat: show scale factor, handle large images, consolidate state Signed-off-by: Evan Song * chore: add types to fs operations Signed-off-by: Evan Song * feat: add date create sorting option Signed-off-by: Evan Song * fix: match name of folder creation modal Signed-off-by: Evan Song * fix: add it here too Signed-off-by: Evan Song * feat: add creation date to file item, file manager header Signed-off-by: Evan Song * chore: a11y Signed-off-by: Evan Song * fix: ensure move item modal always has leading slash Signed-off-by: Evan Song * fix: correct move input placeholder Signed-off-by: Evan Song * chore: correct design disparity Signed-off-by: Evan Song * chore: add better affordance on active file item state Signed-off-by: Evan Song * fix: correct instances where we dont sentence case Signed-off-by: Evan Song * chore: clean Signed-off-by: Evan Song * chore: notify that server restarted on saveandrestart Signed-off-by: Evan Song * fix: consolidate error state in file manager Signed-off-by: Evan Song * chore: adjust sizing Signed-off-by: Evan Song * feat: drag and drop file items to move them Signed-off-by: Evan Song * feat: enable ability to drag folders too Signed-off-by: Evan Song * feat: better file movement toasts Signed-off-by: Evan Song * just say u hate me Signed-off-by: Evan Song * feat: uploading indicator for file uploads Signed-off-by: Evan Song * chore: cleaner file item ghost when dragging Signed-off-by: Evan Song * fix: enforce max length and truncate on ghost Signed-off-by: Evan Song * chore: improve item rename toast Signed-off-by: Evan Song * chore: improve item create toast Signed-off-by: Evan Song * feat: undo and redo stack Signed-off-by: Evan Song * fix: confusing behavior where folders were not sorted alphabetically Signed-off-by: Evan Song * feat: find and replace in file editor Signed-off-by: Evan Song * feat: correctly set language mode of file editor Signed-off-by: Evan Song * chore: slop Signed-off-by: Evan Song * chore: actually handle case with multiple dots in file name before setting language Signed-off-by: Evan Song * fix: match move icons in file context/threedot Signed-off-by: Evan Song * feat: upload indicator Signed-off-by: Evan Song * chore: dedupe lockfile again Signed-off-by: Evan Song * lockfile Signed-off-by: Evan Song * fix: file undefinedness Signed-off-by: Evan Song * checkpoint Signed-off-by: Evan Song * checkpoint Signed-off-by: Evan Song * checkpoint Signed-off-by: Evan Song * remove shitty animation logic Signed-off-by: Evan Song * feat: file upload queuer Signed-off-by: Evan Song * chore: only allow editable files to have active affordance Signed-off-by: Evan Song * fix: properly throw pyrofetcherror when rename fails Signed-off-by: Evan Song * feat: cancel file uploads Signed-off-by: Evan Song * chore: clean Signed-off-by: Evan Song --------- Signed-off-by: Evan Song --- .../src/components/ui/servers/FileItem.vue | 204 +- .../components/ui/servers/FileVirtualList.vue | 2 + .../ui/servers/FilesBrowseNavbar.vue | 6 +- .../ui/servers/FilesContextMenu.vue | 8 +- .../ui/servers/FilesCreateItemModal.vue | 5 +- .../ui/servers/FilesEditingNavbar.vue | 8 +- .../ui/servers/FilesImageViewer.vue | 267 +- .../components/ui/servers/FilesLabelBar.vue | 14 + .../ui/servers/FilesMoveItemModal.vue | 9 +- .../ui/servers/ServerSubdomainLabel.vue | 2 +- apps/frontend/src/composables/pyroServers.ts | 85 +- .../src/pages/servers/manage/[id].vue | 19 +- .../servers/manage/[id]/content/index.vue | 12 +- .../src/pages/servers/manage/[id]/files.vue | 606 +++- pnpm-lock.yaml | 2686 +++-------------- 15 files changed, 1394 insertions(+), 2539 deletions(-) create mode 100644 apps/frontend/src/components/ui/servers/FilesLabelBar.vue diff --git a/apps/frontend/src/components/ui/servers/FileItem.vue b/apps/frontend/src/components/ui/servers/FileItem.vue index 78ca6100..06db4103 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 fe470cbe..56125d76 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 3e3ed53a..5cf62b21 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 @@