Files
AstralRinth/packages/ui/src/layouts/shared/files-tab/components/FileNavbar.vue
T
Calum H. 381ea51cce refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes

* feat: move files tab to shared layout structure

* fix: qa

* fix: qa

* fix: bugs

* fix: lint

* fix: admonition cleanup with progress + actions

* fix: cleanup

* fix: modals

* fix: admon title

* fix: i18n standard

* fix: lint + i18n pass

* fix: remove transition

* fix: type errors

* feat: files tab in app

* fix: qa

* fix: backup item minmax

* fix: use ContentPageHeader for server panel

* fix: lint

* fix: lint

* fix: lint

* feat: page leave safety

* fix: lint

* fix: cargo fmt fix

* fix: blank in prod

* fix: content card table stuff

* Revert "fix: blank in prod"

This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace.

* fix: import

* feat: browse worlds/servers flow

* fix: worlds tab parity with content tab

* fix: perf bug + shader filter pill copy

* feat: singleplayer filter

* fix: ordering

* fix: breadcrumbs

* fix: lint

* fix: qa

* feat: store server proj id when adding to a non-linked instance

* fix: lint

* fix: i18n + qa

* fix: conflict

* qa: already installed modal + placeholders not server-specific

* fix: qa

* fix: add + edit server modals

* fix: qa

* fix: security

* fix: devin flags

* fix: lint

* chore: change file to break build cache

* fix: admon

* fix: import path stuff

* feat: qa

* fix: fmt fmt idiot

---------

Signed-off-by: Calum H. <calum@modrinth.com>
2026-03-26 18:55:15 +00:00

439 lines
12 KiB
Vue

<template>
<header
class="@container flex select-none flex-col gap-4"
:aria-label="formatMessage(messages.fileNavigation)"
>
<div v-if="!isEditing" class="flex items-center gap-2 @[800px]:hidden">
<StyledInput
:model-value="searchQuery"
:icon="SearchIcon"
type="search"
name="search"
autocomplete="off"
:placeholder="formatMessage(messages.searchFiles)"
class="!h-10"
input-class="!h-10"
wrapper-class="flex-1 min-w-0"
@update:model-value="$emit('update:searchQuery', $event)"
/>
</div>
<div class="flex items-center justify-between gap-2">
<nav
:aria-label="formatMessage(messages.breadcrumbNavigation)"
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
>
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
<li class="mr-4 flex-shrink-0">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.backToHome)"
type="button"
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="$emit('navigateHome')"
@mouseenter="$emit('prefetchHome')"
>
<HomeIcon />
<span class="sr-only">{{ formatMessage(messages.home) }}</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
<ol
ref="breadcrumbOuter"
class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0"
:class="{ 'breadcrumb-fade-mask': isBreadcrumbOverflowing }"
:style="
isBreadcrumbOverflowing
? { '--scroll-distance': `-${breadcrumbOverflowAmount}px` }
: undefined
"
@mouseenter="onBreadcrumbMouseEnter"
@mouseleave="onBreadcrumbMouseLeave"
>
<TransitionGroup
ref="breadcrumbInner"
name="breadcrumb"
tag="span"
class="relative flex w-fit items-center"
:class="{ 'breadcrumbs-scroll': isBreadcrumbAnimating }"
@animationiteration="onBreadcrumbAnimationIteration"
>
<li
v-for="(segment, index) in breadcrumbs"
:key="`${segment || index}-group`"
class="relative flex shrink-0 items-center text-sm"
>
<div class="flex shrink-0 items-center">
<ButtonStyled type="transparent">
<button
class="cursor-pointer whitespace-nowrap focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:aria-current="
!isEditing && index === breadcrumbs.length - 1 ? 'location' : undefined
"
:class="{
'!text-contrast': !isEditing && index === breadcrumbs.length - 1,
}"
@click="$emit('navigate', index)"
>
{{ segment || '' }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1 || isEditing"
class="size-4 flex-shrink-0 text-secondary"
aria-hidden="true"
/>
</div>
</li>
</TransitionGroup>
<li v-if="isEditing && editingFileName" class="flex items-center px-3 text-base">
<span class="font-semibold !text-contrast" aria-current="location">
{{ editingFileName }}
</span>
</li>
</ol>
</li>
</ol>
</nav>
<div v-if="!isEditing" class="flex flex-shrink-0 items-center gap-2">
<StyledInput
id="search-folder"
:model-value="searchQuery"
:icon="SearchIcon"
type="search"
name="search"
autocomplete="off"
:placeholder="formatMessage(messages.searchFiles)"
class="!h-10 hidden @[800px]:inline-flex"
input-class="!h-10"
wrapper-class="w-full sm:w-[280px]"
@update:model-value="$emit('update:searchQuery', $event)"
/>
<ButtonStyled v-if="showRefreshButton" type="outlined">
<button
type="button"
class="flex !h-10 items-center gap-2 !border-[1px] !border-surface-5"
:disabled="refreshing"
@click="handleRefresh"
>
<RefreshCwIcon
aria-hidden="true"
class="h-5 w-5 transition-transform"
:class="refreshing ? 'animate-spin' : ''"
/>
{{ formatMessage(commonMessages.refreshButton) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
position="bottom"
direction="left"
:aria-label="formatMessage(messages.createNew)"
:disabled="disabled"
:tooltip="disabled ? disabledTooltip : undefined"
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
:options="[
{ id: 'file', action: () => $emit('create', 'file') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
{ divider: true, shown: showInstallFromUrl ?? false },
{ id: 'upload-zip', shown: false, action: () => $emit('uploadZip') },
{
id: 'install-from-url',
shown: showInstallFromUrl ?? false,
action: () => $emit('unzipFromUrl', false),
},
{
id: 'install-cf-pack',
shown: showInstallFromUrl ?? false,
action: () => $emit('unzipFromUrl', true),
},
]"
>
<PlusIcon aria-hidden="true" class="h-5 w-5" />
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
<template #file>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newFile) }}
</template>
<template #directory>
<FolderOpenIcon aria-hidden="true" /> {{ formatMessage(messages.newFolder) }}
</template>
<template #upload>
<UploadIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFile) }}
</template>
<template #upload-zip>
<FileArchiveIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFromZip) }}
</template>
<template #install-from-url>
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFromZipUrl) }}
</template>
<template #install-cf-pack>
<CurseForgeIcon aria-hidden="true" />
{{ formatMessage(messages.installCurseForgePack) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<div v-else-if="!isEditingImage && isLogFile" class="flex gap-2">
<Button
v-tooltip="formatMessage(messages.shareToMclogs)"
icon-only
transparent
:aria-label="formatMessage(messages.shareToMclogs)"
@click="$emit('share')"
>
<ShareIcon />
</Button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import {
BoxIcon,
ChevronRightIcon,
CurseForgeIcon,
DropdownIcon,
FileArchiveIcon,
FolderOpenIcon,
HomeIcon,
LinkIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
ShareIcon,
UploadIcon,
} from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Button from '#ui/components/base/Button.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const messages = defineMessages({
fileNavigation: {
id: 'files.navbar.file-navigation',
defaultMessage: 'File navigation',
},
breadcrumbNavigation: {
id: 'files.navbar.breadcrumb-navigation',
defaultMessage: 'Breadcrumb navigation',
},
backToHome: {
id: 'files.navbar.back-to-home',
defaultMessage: 'Back to home',
},
home: {
id: 'files.navbar.home',
defaultMessage: 'Home',
},
searchFiles: {
id: 'files.navbar.search-files',
defaultMessage: 'Search files',
},
createNew: {
id: 'files.navbar.create-new',
defaultMessage: 'Create new...',
},
newFile: {
id: 'files.navbar.new-file',
defaultMessage: 'New file',
},
newFolder: {
id: 'files.navbar.new-folder',
defaultMessage: 'New folder',
},
uploadFile: {
id: 'files.navbar.upload-file',
defaultMessage: 'Upload file',
},
uploadFromZip: {
id: 'files.navbar.upload-from-zip',
defaultMessage: 'Upload from .zip file',
},
uploadFromZipUrl: {
id: 'files.navbar.upload-from-zip-url',
defaultMessage: 'Upload from .zip URL',
},
installCurseForgePack: {
id: 'files.navbar.install-curseforge-pack',
defaultMessage: 'Install CurseForge pack',
},
shareToMclogs: {
id: 'files.navbar.share-to-mclogs',
defaultMessage: 'Share to mclo.gs',
},
})
const props = defineProps<{
breadcrumbs: string[]
isEditing: boolean
editingFileName?: string
editingFilePath?: string
isEditingImage?: boolean
searchQuery: string
showRefreshButton?: boolean
showInstallFromUrl?: boolean
baseId: string
disabled?: boolean
disabledTooltip?: string
}>()
const emit = defineEmits<{
navigate: [index: number]
navigateHome: []
prefetchHome: []
'update:searchQuery': [value: string]
create: [type: 'file' | 'directory']
upload: []
uploadZip: []
unzipFromUrl: [cf: boolean]
refresh: []
share: []
}>()
const refreshing = ref(false)
function handleRefresh() {
emit('refresh')
refreshing.value = true
setTimeout(() => {
refreshing.value = false
}, 1000)
}
const breadcrumbOuter = ref<HTMLElement | null>(null)
const breadcrumbInner = ref<{ $el: HTMLElement } | null>(null)
const isBreadcrumbOverflowing = ref(false)
const isBreadcrumbAnimating = ref(false)
const breadcrumbOverflowAmount = ref(0)
let bcHovered = false
let bcStopping = false
function checkBreadcrumbOverflow() {
const inner = breadcrumbInner.value?.$el
if (!breadcrumbOuter.value || !inner) return
const overflow = inner.scrollWidth - breadcrumbOuter.value.clientWidth
isBreadcrumbOverflowing.value = overflow > 0
breadcrumbOverflowAmount.value = overflow + 12
}
function onBreadcrumbMouseEnter() {
bcHovered = true
bcStopping = false
if (isBreadcrumbOverflowing.value) {
isBreadcrumbAnimating.value = true
}
}
function onBreadcrumbMouseLeave() {
bcHovered = false
if (isBreadcrumbAnimating.value) {
bcStopping = true
}
}
function onBreadcrumbAnimationIteration() {
if (bcStopping && !bcHovered) {
isBreadcrumbAnimating.value = false
bcStopping = false
}
}
let bcResizeObserver: ResizeObserver | null = null
onMounted(() => {
checkBreadcrumbOverflow()
bcResizeObserver = new ResizeObserver(checkBreadcrumbOverflow)
if (breadcrumbOuter.value) bcResizeObserver.observe(breadcrumbOuter.value)
const innerEl = breadcrumbInner.value?.$el
if (innerEl) bcResizeObserver.observe(innerEl)
})
onBeforeUnmount(() => {
bcResizeObserver?.disconnect()
})
watch(
() => props.breadcrumbs,
() => {
requestAnimationFrame(checkBreadcrumbOverflow)
},
)
const isLogFile = computed(() => {
return (
props.editingFilePath?.startsWith('logs') ||
props.editingFilePath?.startsWith('crash-reports') ||
props.editingFilePath?.endsWith('.log')
)
})
</script>
<style scoped>
.breadcrumb-move,
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.2s ease;
}
.breadcrumb-enter-from {
opacity: 0;
transform: translateX(-10px) scale(0.9);
}
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.8);
filter: blur(4px);
}
.breadcrumb-leave-active {
position: relative;
pointer-events: none;
}
.breadcrumb-move {
z-index: 1;
}
.breadcrumb-fade-mask {
mask-image: linear-gradient(
to right,
transparent,
black 12px,
black calc(100% - 12px),
transparent
);
}
.breadcrumbs-scroll {
animation: breadcrumb-scroll 10s ease-in-out infinite;
}
@keyframes breadcrumb-scroll {
0% {
transform: translateX(0);
}
35%,
65% {
transform: translateX(var(--scroll-distance));
}
100% {
transform: translateX(0);
}
}
</style>