You've already forked AstralRinth
693a371d61
* start new server settings tabs * update properties tab to match design * better stying in general tab * feat: add suffix input for hostname field * implement tables for allocations and DNS records * add tags for dns record type * small gap adjustment * polish advanced page * adjust properties page hierarchy * fix searching properties, empty state and projection radius appearing * pnpm prepr * update copy to match designs * fix suffix input component * style fixes and match heading size * small fix * fix search allocations placeholder * adjust table styles * move all installation settings helper text to below input * update icon to use overflow menu buttons * fix modal to be consistent * open advanced properties when search * remove other and custom properties, and update styles * remove hide/show all java versions * handle mc 26 * refactor: move server settings pages into /ui and add app ServerSettingsModal * hook up server pages for app * add server page header to app * hook up server settings modal * use large size * fix card box shadow style * fix hostname input for app * fix app/website card containers * implement external tabs for billing and admin billing * fix save banner fixed to parent instead of page body * remove unused prop to FriendsList causing warning in app * fix client-only not available for app * fix bottom cut off * wire node auth * implement full copy buttons * dedup copy button tailwind styles * fix hover class not working in @apply * fix spacing * fix error validation styles * apply consistent styles and spacing * feat: update hosting server card (#5609) * fix type errors * fix some stylesheets not imported for storybook * add server listing stories * add fix for frontend stylesheet imports * remove props. * convert copy code to use tailwind * update server listing component styles * update server info label styles * start status/player count info label, more style updates and fixes * add new server card buttons * hook up server cards and implement updated styles * hook up on download button * fix tauri throwing error when api returns 204 No Content * hook up purchase server modal in app * fix upgrading state loading icon * pnpm prepr * filter out servers past 30 days after cancellation * do not apply opacity on lock or spiner icons * fix disabled server icon background * update pending change stage * handle known suspension states * refactor: reduce code duplication for server listing * update disabled state text color * fix loading icon color * clean up copy * fix disabled opacity for server card * update server listing files kept to be countdown * implement resubscribe modal * implement proper provisioning state for resubscribe * fix duplicate attribute and pnpm prepr * feat: add shared UI package auth DI * feat: update purchase server flow (#5714) * implement server list empty state component * fix stories and adjust spacing * implement select plan design refresh * implement auth for empty server list * use refs instead of reactive * pnpm prepr * fix auth usage for empty servers list * move app auth provider setup to src/providers/setup * pnpm prepr * fix max height * style fix * fix getCreds no auth is blocking api client * implement servers guest plan modal and signin which redirects back to modal's next step * refactor guest plan select logic into provider * implement sign in or create account popup * remove force empty serverList * add download button for suspended mod and generic * add handling for when user logs out * QA pass style fixes * more consistent page styles * fix duplicate export * refactor: remove all fallback stuff from resubscribe modal * implement shared download latest backup util * i18n pass * pnpm prepr * fix region being selected if ping failed * pnpm prepr * feat: servers in app finalization (#5744) * feat: start on shared console implementation into logs and overview pages * fix: terminal gap issues * feat: swap word wrap for full screen * fix: stats cards alignment * fix: stats * feat: fix console clear + remove copy * fix: lint * fix: use reset not clear * feat: shared server header & overview page for app and website (#5736) * feat: implement shared server header for app and website * feat: implement wrapped overview page with shared composable and hook it up * pnpm prepr * fix: bugs * qa: cleanup * feat: root.vue shared layout * feat: delete old options pages + fix discovery frontend * fix: discovery * fix: misc style/layout issues * fix page padding * fix: modal height jankiness * feat: implement server install content in app and server setup modal with DI * fix: spacing * remove servers in app feature flag * Revert "remove servers in app feature flag" This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2. * fix: qa * feat: remove legacy components from apps/frontend/src/components/ui/servers --------- Co-authored-by: Calum H. (IMB11) <contact@cal.engineer> * qa pass (#5738) * fix: qa * feat: qa * fix: server icon fetch fails due to global node auth race condition overriding each other * fix: lint * fix: server icon upload/sync and centralize logic * fix: server settings modal not closing for server reset * fix: better server sorting * feat: copy address in server listing card * fix: notification panel in modal and when overlapping with action bar * fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag * feat: use floating action bar for save banner * fix: saving state in save bar * fix: edit server icon styling * fix: confirm modal to have consistent buttons * feat: loading animation for server panel + caching improvements for app * pnpm prepr * feat: search page deduplication (#5754) * fix: action bar behind modal * fix: remove warning modal for stopping * fix: server cards states * we hate webkit we hate webkit * fix: update allocation creation to not use modal * fix: properties tab spacing and styles * feat: add files tab copy * fix: advanced properties icon * fix: remove back to all servers link * feat: add files tab link in copy * fix: server header styles to be consistent with instance * fix: add header icons back * feat: update instance settings icon to be consistent * fix: icon container * feat: upload state persistence across tabs * fix: server labels text wrapping * fix: use surface-5 border * fix: loading spinner showing with onboarding below * feat: new server button shows purchase modal in website * fix: billing page not showing quarterly interval * fix: server downgrade not showing updated subscription notification * fix: server settings invalidate saved state and remove server context provider since its already provided in the page * pnpm prepr * add stripe publishable key to app build * feat: console highlighting * fix: rename servers title to modrinth hosting * feat: search fix * fix: qa/styles * fix: ip click active and remove power dont ask again * fix: qa * feat: highlighting fix console * fix: disable conflicts action * fix: error dismiss bug * feat: modal clarification * fix: files perms issue * fix: lint * feat: modal fix * enable show uptime * fix: add loading state to edit server icon * fix: notification panel take in has sidebar from settings * fix: consistency pass on app settings * fix: consistency pass on instance settings * pnpm prepr * fix: nagivate to billing button in app to go to website * fix: stripe return url in app causing app to open modrinth.com in tauri * refactor: better show polling UI code * fix: new server polling comparison to use server ids instead of length * fix: buttonstyled story * fix: button styling * fix: content.vue regression * feat: project url redirects * fix: breadcrumbs * fix: purchase with newly added card * fix: console ordering problems * fix: app-frontend missing env config and staging environment * fix: log syncing for instances and server panel accidentally * fix: QA issues * fix: server page loading state * fix: stats card logic * fix: lint * fix: qa * fix: console height padding * fix: terminal padding + loading indicator * feat: update medal server listing styling * fix: no upgrade button for medal server listing in app * fix: go to overview instead of content tab after onboarding * fix: qa * fix: teleport modals to body * fix: logs tab + qa * fix: local storage for user preferences * fix: qa loading indic * feat: considitonal debug and trace * fix: jump to top on install bug * feat: swap out server hard drive icon to server stack icon * feat: servers in app feature flag default true * fix: highlight row ufll * fix: webkit thing onto a tag * fix: input field * fix: clear fix * fix: lint * fix: fmt * feat: improve share modal and bring it back for sharing log * pnpm prepr * fix: menu overflowing * feat: remove servers in app feature flag * fix: server stat charts no longer showing color * fix: library nav no primary state * fix: better modal height and width * fix: highlighting bugs * fix: empty states * fix: delay import to fix overview page slow load on MacOS * fix: medal server listing too bright on light mode * fix: admon analysis + fix logs * fix: bug * fix: clear purchase intent from sign-in after closing modal * performance: improve server manage stats loading by splitting reactivity * fix: deploy + admon + disable highlighting * fix: clippy --------- Co-authored-by: tdgao <mr.trumgao@gmail.com> Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com> * feat: temp wrangler * fix: lint * fix: logs upload * fix: console empty state and admon regressions * fix: fields * feat: log deleting + prefetch for Logs.vue * feat: move delete before share * feat: clear endpoint * feat: we ball! --------- Co-authored-by: Calum H. <calum@modrinth.com> Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
728 lines
22 KiB
Vue
728 lines
22 KiB
Vue
<template>
|
|
<slot name="modals" />
|
|
<FileUnsavedChangesModal ref="unsavedChangesModal" />
|
|
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
|
|
<FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" />
|
|
<FileUploadZipUrlModal v-if="ctx.showInstallFromUrl" ref="uploadZipUrlModal" />
|
|
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
|
|
<FileMoveItemModal
|
|
ref="moveItemModal"
|
|
:item="selectedItem"
|
|
:current-path="ctx.currentPath.value"
|
|
@move="handleMoveItem"
|
|
/>
|
|
<FileDeleteItemModal ref="deleteItemModal" :item="selectedItem" @delete="handleDeleteItem" />
|
|
<FileContextMenu ref="contextMenuRef">
|
|
<template #extract
|
|
><PackageOpenIcon class="size-5" />
|
|
{{ formatMessage(commonMessages.extractButton) }}</template
|
|
>
|
|
<template #rename
|
|
><EditIcon class="size-5" /> {{ formatMessage(commonMessages.renameButton) }}</template
|
|
>
|
|
<template #move
|
|
><RightArrowIcon class="size-5" /> {{ formatMessage(commonMessages.moveButton) }}</template
|
|
>
|
|
<template #download
|
|
><DownloadIcon class="size-5" />
|
|
{{ ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton) }}</template
|
|
>
|
|
<template #delete
|
|
><TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
|
>
|
|
</FileContextMenu>
|
|
<Transition name="fade" mode="out-in">
|
|
<div
|
|
v-if="ctx.loading.value && items.length === 0"
|
|
key="loading"
|
|
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
|
>
|
|
<SpinnerIcon class="animate-spin" />
|
|
{{ formatMessage(messages.loadingFiles) }}
|
|
</div>
|
|
|
|
<div v-else key="content" class="contents">
|
|
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
|
|
<template #header>{{ ctx.busyWarning.value }}</template>
|
|
{{ formatMessage(messages.busyWarning) }}
|
|
</Admonition>
|
|
<div class="relative flex w-full flex-col">
|
|
<div class="relative isolate flex w-full flex-col gap-4">
|
|
<FileNavbar
|
|
:breadcrumbs="breadcrumbSegments"
|
|
:is-editing="isEditing"
|
|
:editing-file-name="ctx.editingFile.value?.name"
|
|
:editing-file-path="ctx.editingFile.value?.path"
|
|
:is-editing-image="fileEditorRef?.isEditingImage"
|
|
:search-query="searchQuery"
|
|
:show-refresh-button="showRefreshButton"
|
|
:show-install-from-url="ctx.showInstallFromUrl"
|
|
:base-id="baseId"
|
|
:disabled="isBusy"
|
|
:disabled-tooltip="busyTooltip"
|
|
@navigate="navigateToSegment"
|
|
@navigate-home="() => navigateToSegment(-1)"
|
|
@prefetch-home="handlePrefetchHome"
|
|
@update:search-query="searchQuery = $event"
|
|
@create="showCreateModal"
|
|
@upload="initiateFileUpload"
|
|
@upload-zip="() => {}"
|
|
@unzip-from-url="showUnzipFromUrlModal"
|
|
@refresh="ctx.refresh"
|
|
@share="() => fileEditorRef?.shareToMclogs()"
|
|
/>
|
|
|
|
<div v-if="!isEditing">
|
|
<FileUploadDragAndDrop
|
|
ref="fileUploadRef"
|
|
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
|
@files-dropped="handleDroppedFiles"
|
|
>
|
|
<FileTableHeader
|
|
:sort-field="sortField"
|
|
:sort-desc="sortDescValue"
|
|
:all-selected="allSelected"
|
|
:some-selected="someSelected"
|
|
:is-stuck="isLabelBarStuck"
|
|
@sort="handleSort"
|
|
@toggle-all="toggleSelectAll"
|
|
/>
|
|
<div
|
|
v-if="filteredItems.length > 0"
|
|
ref="virtualListContainer"
|
|
class="relative w-full"
|
|
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
|
|
>
|
|
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
|
|
<FileTableRow
|
|
v-for="(item, idx) in visibleItems"
|
|
:key="item.path"
|
|
:count="item.count"
|
|
:created="item.created"
|
|
:modified="item.modified"
|
|
:name="item.name"
|
|
:path="item.path"
|
|
:type="item.type"
|
|
:size="item.size"
|
|
:index="visibleRange.start + idx"
|
|
:is-last="visibleRange.start + idx === filteredItems.length - 1"
|
|
:selected="selectedItems.has(item.path)"
|
|
:write-disabled="isBusy"
|
|
:write-disabled-tooltip="busyTooltip"
|
|
@extract="() => handleExtractItem(item)"
|
|
@delete="() => showDeleteModal(item)"
|
|
@rename="() => showRenameModal(item)"
|
|
@download="() => handleDownload(item)"
|
|
@move="() => showMoveModal(item)"
|
|
@move-direct-to="handleDirectMove"
|
|
@edit="() => handleEditFile(item)"
|
|
@navigate="() => handleNavigateToFolder(item)"
|
|
@hover="() => handleItemHover(item)"
|
|
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
|
|
@toggle-select="() => toggleItemSelection(item.path)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else-if="items.length === 0 && !ctx.error.value"
|
|
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 p-20"
|
|
>
|
|
<div class="flex flex-col items-center gap-4 text-center">
|
|
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
|
<h3 class="m-0 text-2xl font-bold text-contrast">
|
|
{{ formatMessage(messages.emptyFolderTitle) }}
|
|
</h3>
|
|
<p class="m-0 text-sm text-secondary">
|
|
{{ formatMessage(messages.emptyFolderDescription) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<FileManagerError
|
|
v-else-if="ctx.error.value"
|
|
class="rounded-b-[20px]"
|
|
:title="formatMessage(messages.errorTitle)"
|
|
:message="formatMessage(messages.errorMessage)"
|
|
@refetch="ctx.refresh"
|
|
@home="navigateToSegment(-1)"
|
|
/>
|
|
</FileUploadDragAndDrop>
|
|
</div>
|
|
<FileEditor
|
|
v-else
|
|
ref="fileEditorRef"
|
|
:file="ctx.editingFile.value"
|
|
:editor-component="editorComponent"
|
|
@close="handleEditorClose"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<FloatingActionBar :shown="hasUnsavedChanges">
|
|
<p class="m-0 text-sm font-semibold md:text-base">
|
|
{{ formatMessage(messages.unsavedChanges) }}
|
|
</p>
|
|
<div class="ml-auto flex gap-2">
|
|
<ButtonStyled type="transparent">
|
|
<button @click="fileEditorRef?.revertChanges()">
|
|
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="brand">
|
|
<button @click="fileEditorRef?.saveFileContent(false)">
|
|
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</FloatingActionBar>
|
|
<FloatingActionBar :shown="selectedItems.size > 0">
|
|
<div class="flex items-center gap-0.5">
|
|
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
|
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
|
|
</span>
|
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
|
<ButtonStyled type="transparent">
|
|
<button class="!text-primary" @click="deselectAll">
|
|
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-0.5">
|
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
|
<ButtonStyled
|
|
type="transparent"
|
|
color="red"
|
|
color-fill="text"
|
|
hover-color-fill="background"
|
|
>
|
|
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
|
|
<TrashIcon />
|
|
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</FloatingActionBar>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {
|
|
DownloadIcon,
|
|
EditIcon,
|
|
FolderOpenIcon,
|
|
HistoryIcon,
|
|
PackageOpenIcon,
|
|
RightArrowIcon,
|
|
SaveIcon,
|
|
SpinnerIcon,
|
|
TrashIcon,
|
|
} from '@modrinth/assets'
|
|
import type { Component } from 'vue'
|
|
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
|
|
|
import Admonition from '#ui/components/base/Admonition.vue'
|
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
|
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
|
import { useStickyObserver } from '#ui/composables/sticky-observer'
|
|
import { useVirtualScroll } from '#ui/composables/virtual-scroll'
|
|
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
|
import { commonMessages } from '#ui/utils/common-messages'
|
|
import { getFileExtension } from '#ui/utils/file-extensions'
|
|
|
|
import FileEditor from './components/editor/FileEditor.vue'
|
|
import FileContextMenu from './components/FileContextMenu.vue'
|
|
import FileManagerError from './components/FileManagerError.vue'
|
|
import FileNavbar from './components/FileNavbar.vue'
|
|
import FileTableHeader from './components/FileTableHeader.vue'
|
|
import FileTableRow from './components/FileTableRow.vue'
|
|
import FileCreateItemModal from './components/modals/FileCreateItemModal.vue'
|
|
import FileDeleteItemModal from './components/modals/FileDeleteItemModal.vue'
|
|
import FileMoveItemModal from './components/modals/FileMoveItemModal.vue'
|
|
import FileRenameItemModal from './components/modals/FileRenameItemModal.vue'
|
|
import FileUnsavedChangesModal from './components/modals/FileUnsavedChangesModal.vue'
|
|
import FileUploadConflictModal from './components/modals/FileUploadConflictModal.vue'
|
|
import FileUploadZipUrlModal from './components/modals/FileUploadZipUrlModal.vue'
|
|
import FileUploadDragAndDrop from './components/upload/FileUploadDragAndDrop.vue'
|
|
import { useFileSearch } from './composables/file-search'
|
|
import { useFileSelection } from './composables/file-selection'
|
|
import { useFileSorting } from './composables/file-sorting'
|
|
import { useFileUndoRedo } from './composables/file-undo-redo'
|
|
import { injectFileManager } from './providers/file-manager'
|
|
import type { FileContextMenuOption, FileItem } from './types'
|
|
|
|
const { formatMessage } = useVIntl()
|
|
|
|
const messages = defineMessages({
|
|
loadingFiles: {
|
|
id: 'files.layout.loading',
|
|
defaultMessage: 'Loading files...',
|
|
},
|
|
busyWarning: {
|
|
id: 'files.layout.busy-warning',
|
|
defaultMessage: 'File operations are disabled while the operation is in progress.',
|
|
},
|
|
emptyFolderTitle: {
|
|
id: 'files.layout.empty-folder-title',
|
|
defaultMessage: 'This folder is empty',
|
|
},
|
|
emptyFolderDescription: {
|
|
id: 'files.layout.empty-folder-description',
|
|
defaultMessage: 'There are no files or folders.',
|
|
},
|
|
errorTitle: {
|
|
id: 'files.layout.error-title',
|
|
defaultMessage: 'Unable to load files',
|
|
},
|
|
errorMessage: {
|
|
id: 'files.layout.error-message',
|
|
defaultMessage: 'The folder may not exist.',
|
|
},
|
|
selectedCount: {
|
|
id: 'files.layout.selected-count',
|
|
defaultMessage: '{count} selected',
|
|
},
|
|
dryRunFailedTitle: {
|
|
id: 'files.layout.dry-run-failed-title',
|
|
defaultMessage: 'Dry run failed',
|
|
},
|
|
dryRunFailedText: {
|
|
id: 'files.layout.dry-run-failed-text',
|
|
defaultMessage: 'Error running dry run',
|
|
},
|
|
extractionStartedTitle: {
|
|
id: 'files.layout.extraction-started-title',
|
|
defaultMessage: 'Extraction started',
|
|
},
|
|
unsavedChanges: {
|
|
id: 'files.layout.unsaved-changes',
|
|
defaultMessage: 'You have unsaved changes.',
|
|
},
|
|
})
|
|
|
|
defineProps<{
|
|
showDebugInfo?: boolean
|
|
showRefreshButton?: boolean
|
|
}>()
|
|
|
|
const { addNotification } = injectNotificationManager()
|
|
const ctx = injectFileManager()
|
|
|
|
const editorComponent = shallowRef<Component | null>(null)
|
|
import('vue3-ace-editor').then(async (mod) => {
|
|
await Promise.all([import('#ui/utils/ace-theme'), import('#ui/utils/ace-mode-log.ts')])
|
|
editorComponent.value = mod.VAceEditor
|
|
})
|
|
|
|
const baseId = `files-${Math.random().toString(36).slice(2, 9)}`
|
|
|
|
const items = computed(() => ctx.items.value)
|
|
const isEditing = computed(() => ctx.editingFile.value !== null)
|
|
const isBusy = computed(() => ctx.isBusy?.value ?? false)
|
|
const busyTooltip = computed(() => ctx.busyTooltip?.value)
|
|
|
|
const breadcrumbSegments = computed(() => {
|
|
const path = ctx.currentPath.value
|
|
if (typeof path === 'string') {
|
|
return path.split('/').filter(Boolean)
|
|
}
|
|
return []
|
|
})
|
|
|
|
// Composables
|
|
const { searchQuery, searchedItems } = useFileSearch(items)
|
|
const {
|
|
sortField,
|
|
sortDesc: sortDescValue,
|
|
handleSort,
|
|
sortedItems: filteredItems,
|
|
resetSort,
|
|
} = useFileSorting(searchedItems)
|
|
|
|
const {
|
|
selectedItems,
|
|
toggleItemSelection,
|
|
deselectAll,
|
|
toggleSelectAll,
|
|
allSelected,
|
|
someSelected,
|
|
} = useFileSelection(filteredItems)
|
|
|
|
const { recordOperation, onKeydown } = useFileUndoRedo(
|
|
(path, newName) => ctx.renameItem(path, newName),
|
|
(source, dest) => ctx.moveItem(source, dest),
|
|
() => ctx.refresh(),
|
|
(title, text, type) => addNotification({ title, text, type }),
|
|
)
|
|
|
|
// Virtual scroll
|
|
const {
|
|
listContainer: virtualListContainer,
|
|
totalHeight,
|
|
visibleRange,
|
|
visibleTop,
|
|
visibleItems,
|
|
} = useVirtualScroll(filteredItems, {
|
|
itemHeight: 61,
|
|
bufferSize: 5,
|
|
})
|
|
|
|
// Sticky observer for the table header
|
|
const fileUploadRef = ref<InstanceType<typeof FileUploadDragAndDrop>>()
|
|
const fileUploadEl = computed(() => fileUploadRef.value?.$el as HTMLElement | null)
|
|
const { isStuck: isLabelBarStuck } = useStickyObserver(fileUploadEl)
|
|
|
|
// Refs
|
|
const fileEditorRef = ref<InstanceType<typeof FileEditor>>()
|
|
const createItemModal = ref<InstanceType<typeof FileCreateItemModal>>()
|
|
const renameItemModal = ref<InstanceType<typeof FileRenameItemModal>>()
|
|
const moveItemModal = ref<InstanceType<typeof FileMoveItemModal>>()
|
|
const deleteItemModal = ref<InstanceType<typeof FileDeleteItemModal>>()
|
|
const uploadConflictModal = ref<InstanceType<typeof FileUploadConflictModal>>()
|
|
const uploadZipUrlModal = ref<InstanceType<typeof FileUploadZipUrlModal>>()
|
|
const contextMenuRef = ref<InstanceType<typeof FileContextMenu>>()
|
|
|
|
const newItemType = ref<'file' | 'directory'>('file')
|
|
const selectedItem = ref<FileItem | null>(null)
|
|
|
|
const unsavedChangesModal = ref<InstanceType<typeof FileUnsavedChangesModal>>()
|
|
|
|
const hasUnsavedChanges = computed(() => fileEditorRef.value?.hasUnsavedChanges ?? false)
|
|
|
|
async function confirmDiscardChanges(): Promise<boolean> {
|
|
if (!hasUnsavedChanges.value) return true
|
|
const result = await unsavedChangesModal.value?.prompt()
|
|
if (result === 'save') {
|
|
await fileEditorRef.value?.saveFileContent(false)
|
|
return true
|
|
}
|
|
return result === 'discard'
|
|
}
|
|
|
|
// Navigation
|
|
async function navigateToSegment(index: number) {
|
|
const newPath = index === -1 ? '/' : breadcrumbSegments.value.slice(0, index + 1).join('/')
|
|
|
|
if (newPath === ctx.currentPath.value && !isEditing.value) {
|
|
return
|
|
}
|
|
|
|
if (isEditing.value) {
|
|
if (!(await confirmDiscardChanges())) return
|
|
ctx.stopEditing()
|
|
}
|
|
|
|
ctx.navigateTo(newPath)
|
|
}
|
|
|
|
function handleNavigateToFolder(item: FileItem) {
|
|
const currentPath = ctx.currentPath.value
|
|
const newPath = currentPath.endsWith('/')
|
|
? `${currentPath}${item.name}`
|
|
: `${currentPath}/${item.name}`
|
|
ctx.navigateTo(newPath)
|
|
}
|
|
|
|
// Editing
|
|
function handleEditFile(item: { name: string; type: string; path: string }) {
|
|
ctx.startEditing({ name: item.name, path: item.path })
|
|
}
|
|
|
|
async function handleEditorClose() {
|
|
if (!(await confirmDiscardChanges())) return
|
|
ctx.stopEditing()
|
|
}
|
|
|
|
// CRUD handlers
|
|
async function handleCreateNewItem(name: string) {
|
|
await ctx.createItem(name, newItemType.value)
|
|
}
|
|
|
|
async function handleRenameItem(newName: string) {
|
|
const item = selectedItem.value
|
|
if (!item) return
|
|
|
|
const path = `${ctx.currentPath.value}/${item.name}`.replace('//', '/')
|
|
await ctx.renameItem(path, newName)
|
|
recordOperation({
|
|
type: 'rename',
|
|
itemType: item.type,
|
|
fileName: item.name,
|
|
path: ctx.currentPath.value,
|
|
oldName: item.name,
|
|
newName,
|
|
})
|
|
}
|
|
|
|
async function handleMoveItem(destination: string) {
|
|
const item = selectedItem.value
|
|
if (!item) return
|
|
|
|
const sourcePath = ctx.currentPath.value
|
|
const source = `${sourcePath}/${item.name}`.replace('//', '/')
|
|
const dest = `${destination}/${item.name}`.replace('//', '/')
|
|
|
|
await ctx.moveItem(source, dest)
|
|
recordOperation({
|
|
type: 'move',
|
|
sourcePath,
|
|
destinationPath: destination,
|
|
fileName: item.name,
|
|
itemType: item.type,
|
|
})
|
|
}
|
|
|
|
function handleDeleteItem() {
|
|
const item = selectedItem.value
|
|
if (!item) return
|
|
|
|
const path = `${ctx.currentPath.value}/${item.name}`.replace('//', '/')
|
|
ctx.deleteItem(path, item.type === 'directory')
|
|
}
|
|
|
|
function handleDirectMove(moveData: {
|
|
name: string
|
|
type: string
|
|
path: string
|
|
destination: string
|
|
}) {
|
|
if (isBusy.value) return
|
|
const dest = `${moveData.destination}/${moveData.name}`.replace('//', '/')
|
|
const sourcePath = moveData.path.substring(0, moveData.path.lastIndexOf('/'))
|
|
|
|
ctx.moveItem(moveData.path, dest).then(() => {
|
|
recordOperation({
|
|
type: 'move',
|
|
sourcePath,
|
|
destinationPath: moveData.destination,
|
|
fileName: moveData.name,
|
|
itemType: moveData.type,
|
|
})
|
|
})
|
|
}
|
|
|
|
// Download
|
|
async function handleDownload(item: FileItem) {
|
|
if (item.type === 'file') {
|
|
await ctx.downloadFile(item.path, item.name)
|
|
}
|
|
}
|
|
|
|
// Extract
|
|
async function handleExtractItem(item: { name: string; type: string; path: string }) {
|
|
if (isBusy.value || !ctx.extractFile) return
|
|
try {
|
|
const dry = await ctx.extractFile(item.path, true, true)
|
|
if (dry) {
|
|
if (dry.conflicting_files.length === 0) {
|
|
handleExtractConfirm(item.path)
|
|
} else {
|
|
uploadConflictModal.value?.show(item.path, dry.conflicting_files)
|
|
}
|
|
} else {
|
|
addNotification({
|
|
title: formatMessage(messages.dryRunFailedTitle),
|
|
text: formatMessage(messages.dryRunFailedText),
|
|
type: 'error',
|
|
})
|
|
}
|
|
} catch (error) {
|
|
addNotification({
|
|
title: formatMessage(commonMessages.extractFailedLabel),
|
|
text: error instanceof Error ? error.message : '',
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
async function handleExtractConfirm(path: string) {
|
|
if (!ctx.extractFile) return
|
|
try {
|
|
await ctx.extractFile(path, true, false)
|
|
addNotification({ title: formatMessage(messages.extractionStartedTitle), type: 'success' })
|
|
} catch (error) {
|
|
addNotification({
|
|
title: formatMessage(commonMessages.extractFailedLabel),
|
|
text: error instanceof Error ? error.message : '',
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Modal show helpers
|
|
function showCreateModal(type: 'file' | 'directory') {
|
|
if (isBusy.value) return
|
|
newItemType.value = type
|
|
createItemModal.value?.show()
|
|
}
|
|
|
|
function showUnzipFromUrlModal(cf: boolean) {
|
|
if (isBusy.value) return
|
|
uploadZipUrlModal.value?.show(cf)
|
|
}
|
|
|
|
function showRenameModal(item: FileItem) {
|
|
if (isBusy.value) return
|
|
selectedItem.value = item
|
|
renameItemModal.value?.show(item)
|
|
}
|
|
|
|
function showMoveModal(item: FileItem) {
|
|
if (isBusy.value) return
|
|
selectedItem.value = item
|
|
moveItemModal.value?.show()
|
|
}
|
|
|
|
function showDeleteModal(item: FileItem) {
|
|
if (isBusy.value) return
|
|
selectedItem.value = item
|
|
deleteItemModal.value?.show()
|
|
}
|
|
|
|
function showBulkDeleteModal() {
|
|
if (isBusy.value) return
|
|
if (selectedItems.value.size === 0) return
|
|
|
|
const itemsToDelete = Array.from(selectedItems.value)
|
|
for (const path of itemsToDelete) {
|
|
const item = items.value.find((i) => i.path === path)
|
|
if (item) {
|
|
ctx.deleteItem(path, item.type === 'directory')
|
|
}
|
|
}
|
|
deselectAll()
|
|
}
|
|
|
|
// Upload
|
|
function handleDroppedFiles(files: File[]) {
|
|
if (isEditing.value || isBusy.value) return
|
|
ctx.uploadFiles(files)
|
|
}
|
|
|
|
function initiateFileUpload() {
|
|
if (isBusy.value) return
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.multiple = true
|
|
input.onchange = () => {
|
|
if (input.files) {
|
|
ctx.uploadFiles(Array.from(input.files))
|
|
}
|
|
}
|
|
input.click()
|
|
}
|
|
|
|
// Prefetch
|
|
let prefetchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
let prefetchHomeTimeout: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function handleItemHover(item: { type: string; path: string; name: string }) {
|
|
if (prefetchTimeout) {
|
|
clearTimeout(prefetchTimeout)
|
|
prefetchTimeout = null
|
|
}
|
|
|
|
if (item.type === 'directory') {
|
|
prefetchTimeout = setTimeout(() => {
|
|
const currentPath = ctx.currentPath.value
|
|
const navPath = currentPath.endsWith('/')
|
|
? `${currentPath}${item.name}`
|
|
: `${currentPath}/${item.name}`
|
|
ctx.prefetchDirectory?.(navPath)
|
|
}, 150)
|
|
} else {
|
|
prefetchTimeout = setTimeout(() => {
|
|
ctx.prefetchFile?.(item.path)
|
|
}, 150)
|
|
}
|
|
}
|
|
|
|
function handlePrefetchHome() {
|
|
if (prefetchHomeTimeout) {
|
|
clearTimeout(prefetchHomeTimeout)
|
|
prefetchHomeTimeout = null
|
|
}
|
|
prefetchHomeTimeout = setTimeout(() => {
|
|
ctx.prefetchDirectory?.('/')
|
|
}, 150)
|
|
}
|
|
|
|
// Context menu
|
|
function handleContextMenu(item: FileItem, x: number, y: number) {
|
|
const wd = isBusy.value
|
|
const wdTooltip = busyTooltip.value
|
|
const isZip = getFileExtension(item.name) === 'zip'
|
|
|
|
const options: FileContextMenuOption[] = [
|
|
{
|
|
id: 'extract',
|
|
shown: isZip && !!ctx.extractFile,
|
|
disabled: wd,
|
|
tooltip: wd ? wdTooltip : undefined,
|
|
action: () => handleExtractItem(item),
|
|
},
|
|
{ divider: true, shown: isZip && !!ctx.extractFile },
|
|
{
|
|
id: 'rename',
|
|
disabled: wd,
|
|
tooltip: wd ? wdTooltip : undefined,
|
|
action: () => showRenameModal(item),
|
|
},
|
|
{
|
|
id: 'move',
|
|
disabled: wd,
|
|
tooltip: wd ? wdTooltip : undefined,
|
|
action: () => showMoveModal(item),
|
|
},
|
|
{
|
|
id: 'download',
|
|
action: () => handleDownload(item),
|
|
shown: item.type !== 'directory',
|
|
},
|
|
{
|
|
id: 'delete',
|
|
disabled: wd,
|
|
tooltip: wd ? wdTooltip : undefined,
|
|
action: () => showDeleteModal(item),
|
|
color: 'red',
|
|
},
|
|
]
|
|
|
|
contextMenuRef.value?.show(item, x, y, options)
|
|
}
|
|
|
|
// Reset search/sort/selection on path change
|
|
watch(
|
|
() => ctx.currentPath.value,
|
|
() => {
|
|
searchQuery.value = ''
|
|
resetSort()
|
|
deselectAll()
|
|
},
|
|
)
|
|
|
|
// Keyboard shortcuts
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', onKeydown)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', onKeydown)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition:
|
|
opacity 300ms ease-in-out,
|
|
transform 300ms ease-in-out;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
transform: scale(0.98);
|
|
}
|
|
</style>
|