Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
353 lines
9.4 KiB
Vue
353 lines
9.4 KiB
Vue
<template>
|
|
<div
|
|
ref="outerRef"
|
|
data-tauri-drag-region
|
|
class="min-w-0 overflow-hidden pl-3"
|
|
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
|
|
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
|
|
@mouseenter="onMouseEnter"
|
|
@mouseleave="onMouseLeave"
|
|
>
|
|
<div
|
|
ref="innerRef"
|
|
data-tauri-drag-region
|
|
class="flex w-fit items-center gap-1"
|
|
:class="{ 'breadcrumbs-scroll': isAnimating }"
|
|
@animationiteration="onAnimationIteration"
|
|
>
|
|
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.name">
|
|
<router-link
|
|
v-if="breadcrumb.link"
|
|
:to="{
|
|
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
|
|
query: breadcrumb.query,
|
|
}"
|
|
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-primary"
|
|
>
|
|
<Avatar
|
|
v-if="resolveIconUrl(breadcrumb)"
|
|
:src="resolveIconUrl(breadcrumb)"
|
|
:alt="resolveLabel(breadcrumb.name)"
|
|
size="20px"
|
|
no-shadow
|
|
raised
|
|
class="shrink-0 !rounded-md"
|
|
/>
|
|
<component
|
|
:is="resolveIcon(breadcrumb)"
|
|
v-else-if="resolveIcon(breadcrumb)"
|
|
class="size-5 shrink-0 text-primary"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ resolveLabel(breadcrumb.name) }}
|
|
</router-link>
|
|
<span
|
|
v-else
|
|
data-tauri-drag-region
|
|
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
|
|
>
|
|
<Avatar
|
|
v-if="resolveIconUrl(breadcrumb)"
|
|
:src="resolveIconUrl(breadcrumb)"
|
|
:alt="resolveLabel(breadcrumb.name)"
|
|
size="20px"
|
|
no-shadow
|
|
raised
|
|
class="shrink-0 !rounded-md"
|
|
/>
|
|
<component
|
|
:is="resolveIcon(breadcrumb)"
|
|
v-else-if="resolveIcon(breadcrumb)"
|
|
class="size-5 shrink-0 text-primary"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ resolveLabel(breadcrumb.name) }}
|
|
</span>
|
|
<ChevronRightIcon
|
|
v-if="index < breadcrumbs.length - 1"
|
|
data-tauri-drag-region
|
|
class="w-5 h-5 shrink-0"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {
|
|
ArrowBigUpDashIcon,
|
|
ChangeSkinIcon,
|
|
ChevronRightIcon,
|
|
CodeIcon,
|
|
CompassIcon,
|
|
DownloadIcon,
|
|
FileTextIcon,
|
|
FlaskConicalIcon,
|
|
FolderIcon,
|
|
GlobeIcon,
|
|
HeartIcon,
|
|
HomeIcon,
|
|
ImagesIcon,
|
|
LibraryIcon,
|
|
MapIcon,
|
|
PackageIcon,
|
|
PencilIcon,
|
|
ServerIcon,
|
|
SettingsIcon,
|
|
} from '@modrinth/assets'
|
|
import { Avatar, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
|
import { type Component, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
|
|
import { resolveBreadcrumbLabel } from '@/helpers/breadcrumb-label'
|
|
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
|
|
|
interface Breadcrumb {
|
|
name: string
|
|
link?: string
|
|
query?: Record<string, string>
|
|
iconUrl?: string | null
|
|
}
|
|
|
|
const route = useRoute()
|
|
const breadcrumbData = useBreadcrumbs()
|
|
const { formatMessage } = useVIntl()
|
|
|
|
const messages = defineMessages({
|
|
home: { id: 'app.navigation.home', defaultMessage: 'Home' },
|
|
worlds: { id: 'app.navigation.worlds', defaultMessage: 'Worlds' },
|
|
discoverContent: {
|
|
id: 'app.navigation.discover-content',
|
|
defaultMessage: 'Discover content',
|
|
},
|
|
skinSelector: { id: 'app.navigation.skin-selector', defaultMessage: 'Skin selector' },
|
|
multiplayer: { id: 'app.navigation.multiplayer', defaultMessage: 'Multiplayer' },
|
|
library: { id: 'app.navigation.library', defaultMessage: 'Library' },
|
|
downloads: { id: 'app.navigation.downloads', defaultMessage: 'Downloads' },
|
|
lab: { id: 'app.navigation.lab', defaultMessage: 'Lab' },
|
|
gradientText: {
|
|
id: 'app.lab.gradient-text.title',
|
|
defaultMessage: 'Gradient text generator',
|
|
},
|
|
seedMap: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
|
|
schematicWorkshop: {
|
|
id: 'app.lab.schematic-preview.title',
|
|
defaultMessage: 'Schematic workshop',
|
|
},
|
|
modTranslation: {
|
|
id: 'app.lab.mod-translation.title',
|
|
defaultMessage: 'Mod translation',
|
|
},
|
|
skinEditor: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
|
|
content: { id: 'app.instance.tabs.content', defaultMessage: 'Content' },
|
|
files: { id: 'app.instance.tabs.files', defaultMessage: 'Files' },
|
|
studio: { id: 'instance.files.studio.title', defaultMessage: 'Studio' },
|
|
logs: { id: 'app.instance.tabs.logs', defaultMessage: 'Logs' },
|
|
editWorld: { id: 'app.navigation.edit-world', defaultMessage: 'Edit world' },
|
|
upgradeInstance: { id: 'app.instance.upgrade-instance', defaultMessage: 'Upgrade instance' },
|
|
})
|
|
|
|
const staticLabels = {
|
|
Home: messages.home,
|
|
Worlds: messages.worlds,
|
|
'Discover content': messages.discoverContent,
|
|
'Skin selector': messages.skinSelector,
|
|
Multiplayer: messages.multiplayer,
|
|
Library: messages.library,
|
|
Downloads: messages.downloads,
|
|
Settings: commonMessages.settingsLabel,
|
|
Lab: messages.lab,
|
|
'Gradient text generator': messages.gradientText,
|
|
'Seed map': messages.seedMap,
|
|
'Schematic workshop': messages.schematicWorkshop,
|
|
'Mod translation': messages.modTranslation,
|
|
'Skin editor': messages.skinEditor,
|
|
Content: messages.content,
|
|
Files: messages.files,
|
|
Studio: messages.studio,
|
|
Logs: messages.logs,
|
|
'Edit world': messages.editWorld,
|
|
Upgrade: messages.upgradeInstance,
|
|
}
|
|
|
|
const staticIcons: Record<string, Component> = {
|
|
Home: HomeIcon,
|
|
Worlds: GlobeIcon,
|
|
'Discover content': CompassIcon,
|
|
'Skin selector': ChangeSkinIcon,
|
|
Multiplayer: ServerIcon,
|
|
Library: LibraryIcon,
|
|
Downloads: DownloadIcon,
|
|
Settings: SettingsIcon,
|
|
Lab: FlaskConicalIcon,
|
|
'Gradient text generator': FlaskConicalIcon,
|
|
'Seed map': MapIcon,
|
|
'Schematic workshop': CodeIcon,
|
|
'Mod translation': CodeIcon,
|
|
'Skin editor': PencilIcon,
|
|
Content: PackageIcon,
|
|
Files: FolderIcon,
|
|
Studio: CodeIcon,
|
|
Logs: FileTextIcon,
|
|
'Edit world': PencilIcon,
|
|
Upgrade: ArrowBigUpDashIcon,
|
|
Favorites: HeartIcon,
|
|
Versions: PackageIcon,
|
|
Gallery: ImagesIcon,
|
|
Screenshots: ImagesIcon,
|
|
'Drop help': FileTextIcon,
|
|
'Recipe generator': FlaskConicalIcon,
|
|
Downloaded: DownloadIcon,
|
|
Modpacks: PackageIcon,
|
|
LibraryServers: ServerIcon,
|
|
Custom: PackageIcon,
|
|
Shared: PackageIcon,
|
|
Saved: HeartIcon,
|
|
}
|
|
|
|
const breadcrumbs = computed<Breadcrumb[]>(() => {
|
|
const additionalContext =
|
|
route.meta.useContext === true
|
|
? breadcrumbData.context
|
|
: route.meta.useRootContext === true
|
|
? breadcrumbData.rootContext
|
|
: null
|
|
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
|
|
if (
|
|
additionalContext?.name.startsWith('?') &&
|
|
crumbs.some((crumb) => crumb.name === additionalContext.name)
|
|
) {
|
|
return crumbs
|
|
}
|
|
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
|
|
})
|
|
|
|
function resolveLabel(name: string): string {
|
|
return resolveBreadcrumbLabel(
|
|
name,
|
|
(key) => breadcrumbData.getName(key) || fallbackDynamicLabel(key),
|
|
staticLabels,
|
|
(message) => formatMessage(message),
|
|
)
|
|
}
|
|
|
|
// 动态面包屑(?Xxx)在页面异步 setName 之前,回退到静态标签,避免图标与文字不同步
|
|
function fallbackDynamicLabel(key: string): string {
|
|
const fallback: Record<string, keyof typeof staticLabels> = {
|
|
BrowseTitle: 'Discover content',
|
|
}
|
|
const staticKey = fallback[key]
|
|
return staticKey ? formatMessage(staticLabels[staticKey]) : ''
|
|
}
|
|
|
|
function resolveIcon(breadcrumb: Breadcrumb): Component | undefined {
|
|
if (breadcrumb.iconUrl || breadcrumbData.getIcon(breadcrumb.name.slice(1))) return undefined
|
|
const dynamicIcons: Record<string, Component> = {
|
|
'?Project': PackageIcon,
|
|
'?Version': PackageIcon,
|
|
'?BrowseTitle': CompassIcon,
|
|
'?FavoritesTitle': HeartIcon,
|
|
}
|
|
if (dynamicIcons[breadcrumb.name]) return dynamicIcons[breadcrumb.name]
|
|
const key = breadcrumb.name.startsWith('?') ? resolveLabel(breadcrumb.name) : breadcrumb.name
|
|
return staticIcons[key]
|
|
}
|
|
|
|
function resolveIconUrl(breadcrumb: Breadcrumb): string | null {
|
|
return (
|
|
breadcrumb.iconUrl ??
|
|
(breadcrumb.name.startsWith('?') ? breadcrumbData.getIcon(breadcrumb.name.slice(1)) : null)
|
|
)
|
|
}
|
|
|
|
// Overflow detection
|
|
const outerRef = ref<HTMLDivElement | null>(null)
|
|
const innerRef = ref<HTMLDivElement | null>(null)
|
|
const isOverflowing = ref(false)
|
|
const isAnimating = ref(false)
|
|
const overflowAmount = ref(0)
|
|
|
|
let hovered = false
|
|
let stopping = false
|
|
|
|
function checkOverflow() {
|
|
if (!outerRef.value || !innerRef.value) return
|
|
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
|
|
isOverflowing.value = overflow > 0
|
|
overflowAmount.value = overflow + 12
|
|
}
|
|
|
|
function onMouseEnter() {
|
|
hovered = true
|
|
stopping = false
|
|
if (isOverflowing.value) {
|
|
isAnimating.value = true
|
|
}
|
|
}
|
|
|
|
function onMouseLeave() {
|
|
hovered = false
|
|
if (isAnimating.value) {
|
|
stopping = true
|
|
}
|
|
}
|
|
|
|
function onAnimationIteration() {
|
|
if (stopping && !hovered) {
|
|
isAnimating.value = false
|
|
stopping = false
|
|
}
|
|
}
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
onMounted(() => {
|
|
checkOverflow()
|
|
resizeObserver = new ResizeObserver(checkOverflow)
|
|
if (outerRef.value) resizeObserver.observe(outerRef.value)
|
|
if (innerRef.value) resizeObserver.observe(innerRef.value)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
resizeObserver?.disconnect()
|
|
})
|
|
|
|
watch(
|
|
breadcrumbs,
|
|
() => {
|
|
breadcrumbData.resetToNames(breadcrumbs.value)
|
|
requestAnimationFrame(checkOverflow)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.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>
|