feat:移除了弹窗,服务器添加sls
This commit is contained in:
6
packages/ui/src/layouts/index.ts
Normal file
6
packages/ui/src/layouts/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './shared/browse-tab'
|
||||
export * from './shared/console'
|
||||
export * from './shared/content-tab'
|
||||
export * from './shared/files-tab'
|
||||
export * from './shared/installation-settings'
|
||||
export * from './wrapped'
|
||||
@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<FloatingActionBar
|
||||
:shown="shown"
|
||||
:aria-label="formatMessage(messages.ariaLabel)"
|
||||
allow-overflow
|
||||
hide-when-modal-open
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-0.5">
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
class="relative h-8 shrink-0"
|
||||
@mouseenter="openProjectPreview"
|
||||
@mouseleave="scheduleProjectPreviewClose"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="project-stack-trigger relative h-8 cursor-pointer rounded-lg p-0 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:style="{ width: `${iconStackWidth}px` }"
|
||||
:aria-controls="projectPreviewId"
|
||||
:aria-expanded="projectPreviewOpen"
|
||||
:aria-label="
|
||||
formatMessage(projectPreviewOpen ? messages.hideProjects : messages.showProjects, {
|
||||
count: selectedCount,
|
||||
})
|
||||
"
|
||||
@focus="openProjectPreview"
|
||||
@blur="scheduleProjectPreviewClose"
|
||||
@click="openProjectPreview"
|
||||
@keydown.esc.prevent.stop="closeProjectPreview"
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
<span
|
||||
v-for="(project, index) in visibleProjects"
|
||||
:key="project.id"
|
||||
v-tooltip="project.name"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
|
||||
:style="{
|
||||
left: `${index * iconStackOffset}px`,
|
||||
zIndex: visibleProjects.length - index,
|
||||
}"
|
||||
>
|
||||
<Avatar
|
||||
:src="project.iconUrl"
|
||||
:alt="project.name"
|
||||
:tint-by="project.id"
|
||||
size="100%"
|
||||
no-shadow
|
||||
class="selected-project-avatar"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="overflowCount > 0"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4 text-xs font-bold text-contrast"
|
||||
:style="{ left: `${visibleProjects.length * iconStackOffset}px`, zIndex: 0 }"
|
||||
>
|
||||
+{{ overflowCount }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="selected-project-preview">
|
||||
<div
|
||||
v-if="projectPreviewOpen && selectedCount > 1"
|
||||
:id="projectPreviewId"
|
||||
class="selected-project-preview absolute bottom-[calc(100%+0.75rem)] left-0 z-20 flex w-[min(18rem,calc(100vw-4rem))] flex-col gap-1 overflow-x-hidden overflow-y-auto rounded-lg border border-solid border-surface-5 bg-surface-2 p-2 shadow-[0px_6px_10px_0px_rgba(0,0,0,0.15),0px_16px_24px_0px_rgba(0,0,0,0.2)]"
|
||||
role="list"
|
||||
@mouseenter="openProjectPreview"
|
||||
@mouseleave="scheduleProjectPreviewClose"
|
||||
>
|
||||
<div
|
||||
v-for="project in selectedProjects"
|
||||
:key="project.id"
|
||||
class="selected-project-preview-item flex min-w-0 items-center gap-2 rounded-md bg-surface-3 px-2 py-1.5"
|
||||
role="listitem"
|
||||
>
|
||||
<Avatar
|
||||
:src="project.iconUrl"
|
||||
:alt="project.name"
|
||||
:tint-by="project.id"
|
||||
size="2rem"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate text-sm font-semibold text-contrast">
|
||||
{{ project.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<span class="px-3 py-2 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ selectedCountText }}
|
||||
</span>
|
||||
<div class="mx-0.5 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
class="!text-primary"
|
||||
:disabled="isInstallingSelected"
|
||||
@click="clearSelected"
|
||||
>
|
||||
<span>{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto shrink-0">
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="isInstallingSelected" @click="installSelected">
|
||||
<PlusIcon />
|
||||
{{ actionButtonText }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@modrinth/assets'
|
||||
import { computed, onUnmounted, ref, useId } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.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 { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectBrowseManager } from '../providers/browse-manager'
|
||||
import type { BrowseInstallContext } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
ariaLabel: {
|
||||
id: 'browse.selected-projects-floating-bar.aria-label',
|
||||
defaultMessage: 'Selected projects',
|
||||
},
|
||||
selectedCount: {
|
||||
id: 'browse.selected-projects-floating-bar.selected-count',
|
||||
defaultMessage: '{count, plural, one {# project selected} other {# projects selected}}',
|
||||
},
|
||||
installButton: {
|
||||
id: 'browse.selected-projects-floating-bar.install',
|
||||
defaultMessage: 'Install {count, plural, one {# project} other {# projects}}',
|
||||
},
|
||||
showProjects: {
|
||||
id: 'browse.selected-projects-floating-bar.show-projects',
|
||||
defaultMessage: 'Show {count, plural, one {# selected project} other {# selected projects}}',
|
||||
},
|
||||
hideProjects: {
|
||||
id: 'browse.selected-projects-floating-bar.hide-projects',
|
||||
defaultMessage: 'Hide selected projects',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
installContext?: BrowseInstallContext | null
|
||||
}>()
|
||||
|
||||
const ctx = injectBrowseManager(null)
|
||||
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
|
||||
const selectedProjects = computed(() => installContext.value?.selectedProjects ?? [])
|
||||
const selectedCount = computed(() => selectedProjects.value.length)
|
||||
const iconStackOffset = 24
|
||||
const isInstallingSelected = computed(() => installContext.value?.isInstallingSelected ?? false)
|
||||
const shown = computed(() => selectedCount.value > 0 || isInstallingSelected.value)
|
||||
const projectPreviewId = `selected-project-preview-${useId()}`
|
||||
const projectPreviewOpen = ref(false)
|
||||
const visibleProjects = computed(() => selectedProjects.value.slice(0, 3))
|
||||
const overflowCount = computed(() => Math.max(0, selectedCount.value - 3))
|
||||
const iconStackWidth = computed(() => {
|
||||
if (selectedCount.value === 0) return 0
|
||||
return (
|
||||
32 + (visibleProjects.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
|
||||
)
|
||||
})
|
||||
const selectedCountText = computed(() =>
|
||||
formatMessage(messages.selectedCount, { count: selectedCount.value }),
|
||||
)
|
||||
const installButtonText = computed(
|
||||
() =>
|
||||
installContext.value?.installButtonLabel ??
|
||||
formatMessage(messages.installButton, { count: selectedCount.value }),
|
||||
)
|
||||
const actionButtonText = computed(() =>
|
||||
isInstallingSelected.value
|
||||
? (installContext.value?.processingLabel ?? installButtonText.value)
|
||||
: installButtonText.value,
|
||||
)
|
||||
|
||||
let projectPreviewCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function openProjectPreview() {
|
||||
if (selectedCount.value < 2) return
|
||||
if (projectPreviewCloseTimer !== null) {
|
||||
clearTimeout(projectPreviewCloseTimer)
|
||||
projectPreviewCloseTimer = null
|
||||
}
|
||||
projectPreviewOpen.value = true
|
||||
}
|
||||
|
||||
function closeProjectPreview() {
|
||||
if (projectPreviewCloseTimer !== null) {
|
||||
clearTimeout(projectPreviewCloseTimer)
|
||||
projectPreviewCloseTimer = null
|
||||
}
|
||||
projectPreviewOpen.value = false
|
||||
}
|
||||
|
||||
function scheduleProjectPreviewClose() {
|
||||
if (projectPreviewCloseTimer !== null) clearTimeout(projectPreviewCloseTimer)
|
||||
projectPreviewCloseTimer = setTimeout(closeProjectPreview, 160)
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
if (isInstallingSelected.value) return
|
||||
void (installContext.value?.clearSelected ?? installContext.value?.clearQueued)?.()
|
||||
}
|
||||
|
||||
function installSelected() {
|
||||
if (isInstallingSelected.value) return
|
||||
void installContext.value?.installSelected?.()
|
||||
}
|
||||
|
||||
onUnmounted(closeProjectPreview)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.selected-project-avatar) {
|
||||
background-color: var(--color-button-bg);
|
||||
}
|
||||
|
||||
.selected-project-preview {
|
||||
max-height: min(18rem, calc(100dvh - 12rem));
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.selected-project-preview-enter-active,
|
||||
.selected-project-preview-leave-active {
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.selected-project-preview-enter-from,
|
||||
.selected-project-preview-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem) scale(0.98);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.selected-project-preview-enter-active,
|
||||
.selected-project-preview-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="warning" :header="formatMessage(messages.header)" max-width="560px">
|
||||
<div class="flex flex-col gap-6">
|
||||
{{ formatMessage(messages.admonitionBody, { count }) }}
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="resolve('cancel')">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="installing" @click="resolve('discard')">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.discardButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button :disabled="installing" @click="resolve('install')">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(commonMessages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'browse.selected-projects-leave-modal.header',
|
||||
defaultMessage: 'Selected projects not installed yet',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'browse.selected-projects-leave-modal.admonition-header',
|
||||
defaultMessage: 'Selected projects not installed yet',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'browse.selected-projects-leave-modal.admonition-body',
|
||||
defaultMessage:
|
||||
'You have selected {count, plural, one {# project} other {# projects}} to install. Install them now or go back without installing them.',
|
||||
},
|
||||
discardButton: {
|
||||
id: 'browse.selected-projects-leave-modal.discard',
|
||||
defaultMessage: 'Discard',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
count: number
|
||||
installing?: boolean
|
||||
}>()
|
||||
|
||||
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
let resolvePromise: ((value: SelectedProjectsLeaveResult) => void) | null = null
|
||||
|
||||
function prompt(): Promise<SelectedProjectsLeaveResult> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
modal.value?.show()
|
||||
})
|
||||
}
|
||||
|
||||
function resolve(result: SelectedProjectsLeaveResult) {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(result)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
defineExpose({ prompt })
|
||||
</script>
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './install-logic'
|
||||
export * from './use-browse-search'
|
||||
@ -0,0 +1,940 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { FilterValue } from '#ui/utils/search'
|
||||
|
||||
export type BrowseInstallContentType =
|
||||
| 'modpack'
|
||||
| 'mod'
|
||||
| 'plugin'
|
||||
| 'datapack'
|
||||
| 'resourcepack'
|
||||
| 'shader'
|
||||
export type BrowseInstallAddonContentType = Exclude<BrowseInstallContentType, 'modpack'>
|
||||
|
||||
/**
|
||||
* Indicates why a concrete version was selected.
|
||||
*
|
||||
* `filtered` means the current browse filters resolved the version.
|
||||
* `target` means filter resolution failed or matched the target exactly, so the server/instance target won.
|
||||
*/
|
||||
export type BrowseInstallPlanSource = 'filtered' | 'target'
|
||||
|
||||
/**
|
||||
* Version constraints used during install resolution.
|
||||
*
|
||||
* Empty arrays and blank values are normalized away, so missing properties mean "do not constrain".
|
||||
*/
|
||||
export interface BrowseInstallPreferences {
|
||||
gameVersions?: string[]
|
||||
loaders?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Server or instance metadata that should be used as the fallback compatibility target.
|
||||
*/
|
||||
export interface BrowseInstallTarget {
|
||||
gameVersion?: string | null
|
||||
loader?: string | null
|
||||
}
|
||||
|
||||
export function usesTargetGameVersion(contentType?: string) {
|
||||
return contentType !== 'modpack' && contentType !== 'resourcepack'
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal project shape needed by shared install resolution.
|
||||
*/
|
||||
export interface BrowseInstallProject {
|
||||
project_id: string
|
||||
latest_version?: string | null
|
||||
version_id?: string | null
|
||||
title?: string
|
||||
name?: string
|
||||
icon_url?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully resolved install work item.
|
||||
*
|
||||
* This is intentionally concrete so queued installs can be flushed later without re-resolving
|
||||
* against filters that may have changed since the user clicked install.
|
||||
*/
|
||||
export interface BrowseInstallPlan<TProject extends BrowseInstallProject = BrowseInstallProject> {
|
||||
project: TProject
|
||||
projectId: string
|
||||
versionId: string
|
||||
versionName?: string
|
||||
versionNumber?: string
|
||||
fileName?: string
|
||||
contentType: BrowseInstallContentType
|
||||
preferences: BrowseInstallPreferences
|
||||
source: BrowseInstallPlanSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Small adapter around caller-owned queue state.
|
||||
*
|
||||
* Callers keep their own reactive storage; shared logic only replaces the whole map.
|
||||
*/
|
||||
export interface BrowseInstallQueue<TProject extends BrowseInstallProject = BrowseInstallProject> {
|
||||
get: () => Map<string, BrowseInstallPlan<TProject>>
|
||||
set: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
|
||||
}
|
||||
|
||||
const serverInstallQueueStoragePrefix = 'server-install-queue'
|
||||
const serverInstallQueueLockStoragePrefix = 'server-install-queue-lock'
|
||||
const serverInstallQueueLockTtl = 15 * 60 * 1000
|
||||
const serverInstallQueueLockRefreshInterval = 30 * 1000
|
||||
const activeInstallQueueFlushes = new Map<string, Promise<unknown>>()
|
||||
|
||||
export function getStoredServerInstallQueueKey(serverId: string | null, worldId: string | null) {
|
||||
if (!serverId || !worldId) return null
|
||||
return `${serverInstallQueueStoragePrefix}:${serverId}:${worldId}`
|
||||
}
|
||||
|
||||
export function readStoredServerInstallQueue<
|
||||
TProject extends BrowseInstallProject = BrowseInstallProject,
|
||||
>(serverId: string | null, worldId: string | null) {
|
||||
const key = getStoredServerInstallQueueKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') {
|
||||
return new Map<string, BrowseInstallPlan<TProject>>()
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return new Map<string, BrowseInstallPlan<TProject>>()
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return new Map<string, BrowseInstallPlan<TProject>>()
|
||||
|
||||
return new Map<string, BrowseInstallPlan<TProject>>(
|
||||
parsed.filter(isStoredServerInstallQueueEntry),
|
||||
)
|
||||
} catch {
|
||||
return new Map<string, BrowseInstallPlan<TProject>>()
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredServerInstallQueue<
|
||||
TProject extends BrowseInstallProject = BrowseInstallProject,
|
||||
>(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
plans: Map<string, BrowseInstallPlan<TProject>>,
|
||||
) {
|
||||
const key = getStoredServerInstallQueueKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return
|
||||
|
||||
if (plans.size === 0) {
|
||||
localStorage.removeItem(key)
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(Array.from(plans.entries())))
|
||||
}
|
||||
|
||||
function getServerInstallQueueLockName(lockKey: string) {
|
||||
return `${serverInstallQueueStoragePrefix}:flush:${lockKey}`
|
||||
}
|
||||
|
||||
function getStoredServerInstallQueueLockKey(lockName: string) {
|
||||
return `${serverInstallQueueLockStoragePrefix}:${lockName}`
|
||||
}
|
||||
|
||||
function isStoredServerInstallQueueLock(value: unknown): value is StoredServerInstallQueueLock {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return typeof record.token === 'string' && typeof record.expiresAt === 'number'
|
||||
}
|
||||
|
||||
function readStoredServerInstallQueueLock(key: string) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
return isStoredServerInstallQueueLock(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function createServerInstallQueueLockToken() {
|
||||
return `${Date.now()}:${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function tryAcquireStoredServerInstallQueueLock(
|
||||
lockName: string,
|
||||
): AcquiredStoredServerInstallQueueLock | null {
|
||||
const key = getStoredServerInstallQueueLockKey(lockName)
|
||||
const existingLock = readStoredServerInstallQueueLock(key)
|
||||
if (existingLock && existingLock.expiresAt > Date.now()) return null
|
||||
|
||||
const token = createServerInstallQueueLockToken()
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
token,
|
||||
expiresAt: Date.now() + serverInstallQueueLockTtl,
|
||||
} satisfies StoredServerInstallQueueLock),
|
||||
)
|
||||
|
||||
const storedLock = readStoredServerInstallQueueLock(key)
|
||||
return storedLock?.token === token ? { key, token } : null
|
||||
}
|
||||
|
||||
function refreshStoredServerInstallQueueLock(lock: AcquiredStoredServerInstallQueueLock) {
|
||||
const storedLock = readStoredServerInstallQueueLock(lock.key)
|
||||
if (storedLock?.token !== lock.token) return false
|
||||
|
||||
localStorage.setItem(
|
||||
lock.key,
|
||||
JSON.stringify({
|
||||
token: lock.token,
|
||||
expiresAt: Date.now() + serverInstallQueueLockTtl,
|
||||
} satisfies StoredServerInstallQueueLock),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function releaseStoredServerInstallQueueLock(lock: AcquiredStoredServerInstallQueueLock) {
|
||||
const storedLock = readStoredServerInstallQueueLock(lock.key)
|
||||
if (storedLock?.token === lock.token) {
|
||||
localStorage.removeItem(lock.key)
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function withStoredServerInstallQueueLock<T>(
|
||||
lockName: string,
|
||||
callback: () => T | Promise<T>,
|
||||
) {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return await callback()
|
||||
}
|
||||
|
||||
let lock = tryAcquireStoredServerInstallQueueLock(lockName)
|
||||
while (!lock) {
|
||||
await wait(100)
|
||||
lock = tryAcquireStoredServerInstallQueueLock(lockName)
|
||||
}
|
||||
|
||||
const acquiredLock = lock
|
||||
const refreshInterval = setInterval(
|
||||
() => refreshStoredServerInstallQueueLock(acquiredLock),
|
||||
serverInstallQueueLockRefreshInterval,
|
||||
)
|
||||
|
||||
try {
|
||||
return await callback()
|
||||
} finally {
|
||||
clearInterval(refreshInterval)
|
||||
releaseStoredServerInstallQueueLock(acquiredLock)
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithServerInstallQueueLock<T>(lockName: string, callback: () => T | Promise<T>) {
|
||||
const locks =
|
||||
typeof navigator === 'undefined' ? undefined : (navigator as NavigatorWithLocks).locks
|
||||
|
||||
if (locks) {
|
||||
return await locks.request(lockName, { mode: 'exclusive' }, callback)
|
||||
}
|
||||
|
||||
return await withStoredServerInstallQueueLock(lockName, callback)
|
||||
}
|
||||
|
||||
async function withServerInstallQueueLock<T>(
|
||||
lockKey: string | null | undefined,
|
||||
callback: () => T | Promise<T>,
|
||||
) {
|
||||
if (!lockKey) return await callback()
|
||||
|
||||
const lockName = getServerInstallQueueLockName(lockKey)
|
||||
for (;;) {
|
||||
const activeFlush = activeInstallQueueFlushes.get(lockName)
|
||||
if (!activeFlush) break
|
||||
await activeFlush.catch(() => undefined)
|
||||
}
|
||||
|
||||
const flush = runWithServerInstallQueueLock(lockName, callback)
|
||||
activeInstallQueueFlushes.set(lockName, flush)
|
||||
|
||||
try {
|
||||
return await flush
|
||||
} finally {
|
||||
if (activeInstallQueueFlushes.get(lockName) === flush) {
|
||||
activeInstallQueueFlushes.delete(lockName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function withStoredServerInstallQueueFlushLock<T>(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
callback: () => T | Promise<T>,
|
||||
) {
|
||||
return await withServerInstallQueueLock(
|
||||
getStoredServerInstallQueueKey(serverId, worldId),
|
||||
callback,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter inputs for deriving selected install preferences.
|
||||
*
|
||||
* Provided filters come from a target context, and overridden filter types are ignored so user
|
||||
* choices can replace the target-provided constraints.
|
||||
*/
|
||||
export interface SelectedInstallPreferencesOptions {
|
||||
contentType: string
|
||||
selectedFilters?: readonly FilterValue[]
|
||||
providedFilters?: readonly FilterValue[]
|
||||
overriddenProvidedFilterTypes?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs for resolving one concrete install plan.
|
||||
*
|
||||
* Version fetching is injected so this module stays platform-agnostic and can be used by both web
|
||||
* and app frontends.
|
||||
*/
|
||||
export interface ResolveInstallPlanOptions<
|
||||
TProject extends BrowseInstallProject,
|
||||
> extends SelectedInstallPreferencesOptions {
|
||||
project: TProject
|
||||
contentType: BrowseInstallContentType
|
||||
targetPreferences?: BrowseInstallPreferences
|
||||
getProjectVersions: (projectId: string) => Promise<Labrinth.Versions.v2.Version[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Install request wrapper around plan resolution.
|
||||
*
|
||||
* Queue mode stores the resolved plan; immediate mode passes it to the caller's install handler.
|
||||
*/
|
||||
export interface RequestInstallOptions<
|
||||
TProject extends BrowseInstallProject,
|
||||
> extends ResolveInstallPlanOptions<TProject> {
|
||||
mode: 'queue' | 'immediate'
|
||||
queue?: BrowseInstallQueue<TProject>
|
||||
install?: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs for committing queued plans without re-running version matching.
|
||||
*/
|
||||
export interface FlushInstallQueueOptions<TProject extends BrowseInstallProject> {
|
||||
queue: BrowseInstallQueue<TProject>
|
||||
install: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
|
||||
lockKey?: string | null
|
||||
onError?: (error: unknown, plan: BrowseInstallPlan<TProject>) => void
|
||||
onProgress?: (
|
||||
completed: number,
|
||||
total: number,
|
||||
plan: BrowseInstallPlan<TProject>,
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface FlushStoredServerAddonInstallQueueOptions<TProject extends BrowseInstallProject> {
|
||||
serverId: string
|
||||
worldId: string
|
||||
install: (plans: BrowseInstallPlan<TProject>[]) => void | Promise<void>
|
||||
onQueueChange?: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a queue flush. Failed plans are also written back to the queue.
|
||||
*/
|
||||
export interface FlushInstallQueueResult<TProject extends BrowseInstallProject> {
|
||||
ok: boolean
|
||||
successfulPlans: BrowseInstallPlan<TProject>[]
|
||||
failedPlans: Map<string, BrowseInstallPlan<TProject>>
|
||||
}
|
||||
|
||||
export interface FlushStoredServerAddonInstallQueueResult<TProject extends BrowseInstallProject> {
|
||||
ok: boolean
|
||||
flushedPlans: BrowseInstallPlan<TProject>[]
|
||||
attemptedPlans: BrowseInstallPlan<TProject>[]
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
interface InstallCandidate {
|
||||
preferences: BrowseInstallPreferences
|
||||
source: BrowseInstallPlanSource
|
||||
}
|
||||
|
||||
interface StoredServerInstallQueueLock {
|
||||
token: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
interface AcquiredStoredServerInstallQueueLock {
|
||||
key: string
|
||||
token: string
|
||||
}
|
||||
|
||||
type NavigatorWithLocks = {
|
||||
locks?: {
|
||||
request: <T>(
|
||||
name: string,
|
||||
options: { mode: 'exclusive' },
|
||||
callback: () => T | Promise<T>,
|
||||
) => Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a project/content type to the browse filter keys that represent its loader.
|
||||
*/
|
||||
export function getLoaderFilterTypes(contentType: string) {
|
||||
if (contentType === 'mod') return ['mod_loader']
|
||||
if (contentType === 'plugin') return ['plugin_loader', 'plugin_platform']
|
||||
if (contentType === 'modpack') return ['modpack_loader']
|
||||
if (contentType === 'shader') return ['shader_loader']
|
||||
if (contentType === 'datapack') return ['datapack_loader']
|
||||
return []
|
||||
}
|
||||
|
||||
const SERVER_RUNTIME_INSTALL_FILTER_TYPES = new Set([
|
||||
'game_version',
|
||||
'mod_loader',
|
||||
'plugin_loader',
|
||||
'plugin_platform',
|
||||
'datapack_loader',
|
||||
])
|
||||
|
||||
export function stripServerRuntimeInstallFilters(filters: readonly FilterValue[]) {
|
||||
return filters.filter((filter) => !SERVER_RUNTIME_INSTALL_FILTER_TYPES.has(filter.type))
|
||||
}
|
||||
|
||||
export function stripServerRuntimeInstallOverrides(filterTypes: readonly string[]) {
|
||||
return filterTypes.filter((type) => !SERVER_RUNTIME_INSTALL_FILTER_TYPES.has(type))
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges user-selected filters with target-provided filters for install decisions.
|
||||
*
|
||||
* User filters win per filter type, provided filters are dropped when overridden, and negative
|
||||
* filters are excluded because they are browse-only constraints.
|
||||
*/
|
||||
export function getEffectiveInstallFilters({
|
||||
selectedFilters = [],
|
||||
providedFilters = [],
|
||||
overriddenProvidedFilterTypes = [],
|
||||
}: Omit<SelectedInstallPreferencesOptions, 'contentType'>) {
|
||||
const effectiveProvidedFilters = providedFilters.filter(
|
||||
(providedFilter) => !overriddenProvidedFilterTypes.includes(providedFilter.type),
|
||||
)
|
||||
const userFilters = selectedFilters.filter(
|
||||
(userFilter) =>
|
||||
!effectiveProvidedFilters.some((providedFilter) => providedFilter.type === userFilter.type),
|
||||
)
|
||||
|
||||
return [...userFilters, ...effectiveProvidedFilters].filter((filter) => !filter.negative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts effective browse filters into install preferences for a specific content type.
|
||||
*/
|
||||
export function getInstallPreferencesFromFilters(
|
||||
contentType: string,
|
||||
filters: readonly FilterValue[],
|
||||
): BrowseInstallPreferences {
|
||||
const loaderFilterTypes = getLoaderFilterTypes(contentType)
|
||||
const gameVersions = uniqueDefined(
|
||||
filters.filter((filter) => filter.type === 'game_version').map((filter) => filter.option),
|
||||
)
|
||||
const loaders = uniqueDefined(
|
||||
filters
|
||||
.filter((filter) => loaderFilterTypes.includes(filter.type))
|
||||
.map((filter) => filter.option),
|
||||
)
|
||||
|
||||
return normalizeInstallPreferences({
|
||||
gameVersions: gameVersions.length > 0 ? gameVersions : undefined,
|
||||
loaders: loaders.length > 0 ? loaders : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the preferences represented by the current browse selection plus active provided filters.
|
||||
*/
|
||||
export function getSelectedInstallPreferences(
|
||||
options: SelectedInstallPreferencesOptions,
|
||||
): BrowseInstallPreferences {
|
||||
return getInstallPreferencesFromFilters(options.contentType, getEffectiveInstallFilters(options))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts server/instance metadata into fallback install preferences.
|
||||
*/
|
||||
export function getTargetInstallPreferences(
|
||||
target: BrowseInstallTarget,
|
||||
contentType?: string,
|
||||
): BrowseInstallPreferences {
|
||||
const gameVersion = target.gameVersion?.trim()
|
||||
const loader = target.loader?.trim()
|
||||
const shouldUseTargetLoader = contentType !== 'modpack'
|
||||
const shouldUseTargetGameVersion = usesTargetGameVersion(contentType)
|
||||
|
||||
return normalizeInstallPreferences({
|
||||
gameVersions: gameVersion && shouldUseTargetGameVersion ? [gameVersion] : undefined,
|
||||
loaders:
|
||||
contentType === 'datapack'
|
||||
? ['datapack']
|
||||
: contentType === 'resourcepack'
|
||||
? ['minecraft']
|
||||
: contentType === 'shader'
|
||||
? ['iris']
|
||||
: loader && shouldUseTargetLoader
|
||||
? [loader]
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes loader identifiers so API and UI aliases compare consistently.
|
||||
*/
|
||||
export function normalizeLoaderAlias(loader: string) {
|
||||
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns aliases that should be considered mutually compatible for install matching.
|
||||
*/
|
||||
export function getCompatibleLoaderAliases(loader: string) {
|
||||
const normalized = normalizeLoaderAlias(loader)
|
||||
if (!normalized) return new Set<string>()
|
||||
if (['paper', 'purpur', 'spigot', 'bukkit'].includes(normalized)) {
|
||||
return new Set(['paper', 'purpur', 'spigot', 'bukkit'])
|
||||
}
|
||||
if (normalized === 'neoforge' || normalized === 'neo') {
|
||||
return new Set(['neoforge', 'neo'])
|
||||
}
|
||||
return new Set([normalized])
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether selected filters conflict with the target constraints.
|
||||
*/
|
||||
export function preferencesDiffer(
|
||||
selected: BrowseInstallPreferences,
|
||||
target: BrowseInstallPreferences,
|
||||
) {
|
||||
return (
|
||||
preferencesConflict(selected.gameVersions, target.gameVersions) ||
|
||||
loaderPreferencesConflict(selected.loaders, target.loaders)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills missing selected preferences from the target.
|
||||
*
|
||||
* This preserves the user's explicit filter choices while still constraining unconstrained axes to
|
||||
* the server/instance target.
|
||||
*/
|
||||
export function mergeInstallPreferences(
|
||||
selected: BrowseInstallPreferences,
|
||||
target: BrowseInstallPreferences,
|
||||
): BrowseInstallPreferences {
|
||||
return normalizeInstallPreferences({
|
||||
gameVersions: selected.gameVersions?.length ? selected.gameVersions : target.gameVersions,
|
||||
loaders: selected.loaders?.length ? selected.loaders : target.loaders,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the newest version matching the given preferences.
|
||||
*/
|
||||
export function getLatestMatchingInstallVersion(
|
||||
versions: readonly Labrinth.Versions.v2.Version[],
|
||||
preferences: BrowseInstallPreferences,
|
||||
) {
|
||||
return [...versions]
|
||||
.filter((version) => versionMatchesPreferences(version, preferences))
|
||||
.sort((a, b) => {
|
||||
const channelDifference =
|
||||
versionChannelRank(a.version_type) - versionChannelRank(b.version_type)
|
||||
if (channelDifference) return channelDifference
|
||||
return new Date(b.date_published).getTime() - new Date(a.date_published).getTime()
|
||||
})[0]
|
||||
}
|
||||
|
||||
function versionChannelRank(versionType: Labrinth.Versions.v2.Version['version_type']) {
|
||||
if (versionType === 'beta') return 1
|
||||
if (versionType === 'alpha') return 2
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the concrete version to install.
|
||||
*
|
||||
* The resolver tries the filtered plan first, with target values filling any missing axes. If that
|
||||
* cannot resolve and differs from the target, it falls back to the target-only plan.
|
||||
*/
|
||||
export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
|
||||
options: ResolveInstallPlanOptions<TProject>,
|
||||
): Promise<BrowseInstallPlan<TProject>> {
|
||||
const projectId = options.project.project_id
|
||||
if (!projectId) {
|
||||
throw new Error('No project is available for install.')
|
||||
}
|
||||
|
||||
const selectedPreferences = getSelectedInstallPreferences(options)
|
||||
const targetPreferences = normalizeInstallPreferences(options.targetPreferences)
|
||||
const candidates = getInstallCandidates(selectedPreferences, targetPreferences)
|
||||
const versions = await options.getProjectVersions(projectId)
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const version = getLatestMatchingInstallVersion(versions, candidate.preferences)
|
||||
|
||||
if (version) {
|
||||
const fileName =
|
||||
version.files.find((file) => file.primary)?.filename ?? version.files[0]?.filename
|
||||
return {
|
||||
project: options.project,
|
||||
projectId,
|
||||
versionId: version.id,
|
||||
versionName: version.name,
|
||||
versionNumber: version.version_number,
|
||||
fileName,
|
||||
contentType: options.contentType,
|
||||
preferences: candidate.preferences,
|
||||
source: candidate.source,
|
||||
}
|
||||
}
|
||||
|
||||
lastError = createNoCompatibleVersionError(options.contentType, candidate.preferences)
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('No version found for this project.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and either queues or immediately installs a project.
|
||||
*
|
||||
* Queue replacement is keyed by project ID, so clicking install again after changing filters
|
||||
* replaces the previously resolved plan.
|
||||
*/
|
||||
export async function requestInstall<TProject extends BrowseInstallProject>(
|
||||
options: RequestInstallOptions<TProject>,
|
||||
) {
|
||||
const plan = await resolveInstallPlan(options)
|
||||
|
||||
if (options.mode === 'queue') {
|
||||
if (!options.queue) {
|
||||
throw new Error('No install queue is available.')
|
||||
}
|
||||
|
||||
const nextPlans = new Map(options.queue.get())
|
||||
nextPlans.set(plan.projectId, plan)
|
||||
options.queue.set(nextPlans)
|
||||
return plan
|
||||
}
|
||||
|
||||
await options.install?.(plan)
|
||||
return plan
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits queued install plans exactly as stored.
|
||||
*
|
||||
* Successful plans are removed; failed plans remain in the queue for retry or user action.
|
||||
*/
|
||||
export async function flushInstallQueue<TProject extends BrowseInstallProject>({
|
||||
queue,
|
||||
install,
|
||||
lockKey,
|
||||
onError,
|
||||
onProgress,
|
||||
}: FlushInstallQueueOptions<TProject>): Promise<FlushInstallQueueResult<TProject>> {
|
||||
return await withServerInstallQueueLock(lockKey, () =>
|
||||
flushInstallQueueUnlocked({ queue, install, onError, onProgress }),
|
||||
)
|
||||
}
|
||||
|
||||
export function getStoredServerAddonInstallQueue<
|
||||
TProject extends BrowseInstallProject = BrowseInstallProject,
|
||||
>(serverId: string, worldId: string) {
|
||||
const storedPlans = readStoredServerInstallQueue<TProject>(serverId, worldId)
|
||||
const addonPlans = new Map(
|
||||
Array.from(storedPlans).filter(([, plan]) => plan.contentType !== 'modpack'),
|
||||
)
|
||||
|
||||
if (addonPlans.size !== storedPlans.size) {
|
||||
writeStoredServerInstallQueue(serverId, worldId, addonPlans)
|
||||
}
|
||||
|
||||
return addonPlans
|
||||
}
|
||||
|
||||
export async function flushStoredServerAddonInstallQueue<TProject extends BrowseInstallProject>({
|
||||
serverId,
|
||||
worldId,
|
||||
install,
|
||||
onQueueChange,
|
||||
}: FlushStoredServerAddonInstallQueueOptions<TProject>): Promise<
|
||||
FlushStoredServerAddonInstallQueueResult<TProject>
|
||||
> {
|
||||
let attemptedPlans: BrowseInstallPlan<TProject>[] = []
|
||||
|
||||
try {
|
||||
const flushedPlans = await withStoredServerInstallQueueFlushLock(
|
||||
serverId,
|
||||
worldId,
|
||||
async () => {
|
||||
const plans = Array.from(
|
||||
getStoredServerAddonInstallQueue<TProject>(serverId, worldId).values(),
|
||||
)
|
||||
attemptedPlans = plans
|
||||
if (plans.length === 0) return []
|
||||
|
||||
await install(plans)
|
||||
|
||||
const remainingPlans = getStoredServerAddonInstallQueue<TProject>(serverId, worldId)
|
||||
for (const plan of plans) {
|
||||
remainingPlans.delete(plan.projectId)
|
||||
}
|
||||
writeStoredServerInstallQueue(serverId, worldId, remainingPlans)
|
||||
onQueueChange?.(remainingPlans)
|
||||
return plans
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
flushedPlans,
|
||||
attemptedPlans,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
flushedPlans: [],
|
||||
attemptedPlans,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function flushInstallQueueUnlocked<TProject extends BrowseInstallProject>({
|
||||
queue,
|
||||
install,
|
||||
onError,
|
||||
onProgress,
|
||||
}: FlushInstallQueueOptions<TProject>): Promise<FlushInstallQueueResult<TProject>> {
|
||||
const queuedPlans = Array.from(queue.get().values())
|
||||
const failedPlans = new Map<string, BrowseInstallPlan<TProject>>()
|
||||
const successfulPlans: BrowseInstallPlan<TProject>[] = []
|
||||
let completed = 0
|
||||
|
||||
for (const plan of queuedPlans) {
|
||||
try {
|
||||
await install(plan)
|
||||
successfulPlans.push(plan)
|
||||
|
||||
const remainingPlans = new Map(queue.get())
|
||||
remainingPlans.delete(plan.projectId)
|
||||
queue.set(remainingPlans)
|
||||
} catch (error) {
|
||||
failedPlans.set(plan.projectId, plan)
|
||||
onError?.(error, plan)
|
||||
} finally {
|
||||
completed++
|
||||
await onProgress?.(completed, queuedPlans.length, plan)
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: failedPlans.size === 0,
|
||||
successfulPlans,
|
||||
failedPlans,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ordered resolution attempts for an install request.
|
||||
*/
|
||||
function getInstallCandidates(
|
||||
selectedPreferences: BrowseInstallPreferences,
|
||||
targetPreferences: BrowseInstallPreferences,
|
||||
): InstallCandidate[] {
|
||||
const filteredPreferences = mergeInstallPreferences(selectedPreferences, targetPreferences)
|
||||
const candidates: InstallCandidate[] = []
|
||||
|
||||
if (hasPreferences(filteredPreferences)) {
|
||||
candidates.push({
|
||||
preferences: filteredPreferences,
|
||||
source: preferencesEquivalent(selectedPreferences, targetPreferences) ? 'target' : 'filtered',
|
||||
})
|
||||
} else {
|
||||
candidates.push({ preferences: {}, source: 'filtered' })
|
||||
}
|
||||
|
||||
if (
|
||||
hasPreferences(targetPreferences) &&
|
||||
preferencesDiffer(filteredPreferences, targetPreferences)
|
||||
) {
|
||||
candidates.push({ preferences: targetPreferences, source: 'target' })
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function hasPreferences(preferences: BrowseInstallPreferences) {
|
||||
return !!preferences.gameVersions?.length || !!preferences.loaders?.length
|
||||
}
|
||||
|
||||
function versionMatchesPreferences(
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
preferences: BrowseInstallPreferences,
|
||||
) {
|
||||
const gameVersionMatches =
|
||||
!preferences.gameVersions?.length ||
|
||||
version.game_versions.some((gameVersion) => preferences.gameVersions?.includes(gameVersion))
|
||||
if (!gameVersionMatches) return false
|
||||
if (!preferences.loaders?.length) return true
|
||||
|
||||
const compatibleLoaders = getCompatibleLoaderAliasSet(preferences.loaders)
|
||||
return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoaderAlias(loader)))
|
||||
}
|
||||
|
||||
function preferencesConflict(
|
||||
selected: readonly string[] | undefined,
|
||||
target: readonly string[] | undefined,
|
||||
) {
|
||||
if (!selected?.length || !target?.length) return false
|
||||
return !selected.some((value) => target.includes(value))
|
||||
}
|
||||
|
||||
function loaderPreferencesConflict(
|
||||
selected: readonly string[] | undefined,
|
||||
target: readonly string[] | undefined,
|
||||
) {
|
||||
if (!selected?.length || !target?.length) return false
|
||||
const selectedLoaders = getCompatibleLoaderAliasSet(selected)
|
||||
const targetLoaders = getCompatibleLoaderAliasSet(target)
|
||||
return !Array.from(selectedLoaders).some((loader) => targetLoaders.has(loader))
|
||||
}
|
||||
|
||||
function preferencesEquivalent(
|
||||
selected: BrowseInstallPreferences,
|
||||
target: BrowseInstallPreferences,
|
||||
) {
|
||||
return (
|
||||
valueSetsEquivalent(selected.gameVersions, target.gameVersions) &&
|
||||
loaderSetsEquivalent(selected.loaders, target.loaders)
|
||||
)
|
||||
}
|
||||
|
||||
function valueSetsEquivalent(
|
||||
selected: readonly string[] | undefined,
|
||||
target: readonly string[] | undefined,
|
||||
) {
|
||||
return setsEquivalent(new Set(selected ?? []), new Set(target ?? []))
|
||||
}
|
||||
|
||||
function loaderSetsEquivalent(
|
||||
selected: readonly string[] | undefined,
|
||||
target: readonly string[] | undefined,
|
||||
) {
|
||||
return setsEquivalent(
|
||||
getCompatibleLoaderAliasSet(selected ?? []),
|
||||
getCompatibleLoaderAliasSet(target ?? []),
|
||||
)
|
||||
}
|
||||
|
||||
function getCompatibleLoaderAliasSet(loaders: readonly string[]) {
|
||||
const aliases = new Set<string>()
|
||||
for (const loader of loaders) {
|
||||
for (const alias of getCompatibleLoaderAliases(loader)) {
|
||||
aliases.add(alias)
|
||||
}
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
function setsEquivalent(a: Set<string>, b: Set<string>) {
|
||||
if (a.size !== b.size) return false
|
||||
return Array.from(a).every((value) => b.has(value))
|
||||
}
|
||||
|
||||
function normalizeInstallPreferences(
|
||||
preferences?: BrowseInstallPreferences,
|
||||
): BrowseInstallPreferences {
|
||||
return {
|
||||
gameVersions: uniqueDefined(preferences?.gameVersions),
|
||||
loaders: uniqueDefined(preferences?.loaders),
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueDefined(values: readonly (string | null | undefined)[] = []) {
|
||||
return Array.from(
|
||||
new Set(values.map((value) => value?.trim()).filter((value): value is string => !!value)),
|
||||
)
|
||||
}
|
||||
|
||||
function isStoredServerInstallQueueEntry(
|
||||
value: unknown,
|
||||
): value is [string, BrowseInstallPlan<BrowseInstallProject>] {
|
||||
if (!Array.isArray(value) || value.length !== 2) return false
|
||||
const [key, plan] = value
|
||||
return typeof key === 'string' && isStoredBrowseInstallPlan(plan)
|
||||
}
|
||||
|
||||
function isStoredBrowseInstallPlan(
|
||||
value: unknown,
|
||||
): value is BrowseInstallPlan<BrowseInstallProject> {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
isStoredBrowseInstallProject(record.project) &&
|
||||
typeof record.projectId === 'string' &&
|
||||
typeof record.versionId === 'string' &&
|
||||
isStoredBrowseInstallContentType(record.contentType) &&
|
||||
isStoredBrowseInstallPreferences(record.preferences) &&
|
||||
(record.source === 'filtered' || record.source === 'target')
|
||||
)
|
||||
}
|
||||
|
||||
function isStoredBrowseInstallProject(value: unknown): value is BrowseInstallProject {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
typeof (value as Record<string, unknown>).project_id === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isStoredBrowseInstallContentType(value: unknown): value is BrowseInstallContentType {
|
||||
return value === 'modpack' || value === 'mod' || value === 'plugin' || value === 'datapack'
|
||||
}
|
||||
|
||||
function isStoredBrowseInstallPreferences(value: unknown): value is BrowseInstallPreferences {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return isOptionalStringArray(record.gameVersions) && isOptionalStringArray(record.loaders)
|
||||
}
|
||||
|
||||
function isOptionalStringArray(value: unknown) {
|
||||
return (
|
||||
value === undefined || (Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
||||
)
|
||||
}
|
||||
|
||||
function createNoCompatibleVersionError(
|
||||
contentType: BrowseInstallContentType,
|
||||
preferences: BrowseInstallPreferences,
|
||||
) {
|
||||
const versionLabel = preferences.gameVersions?.length
|
||||
? preferences.gameVersions.join(', ')
|
||||
: 'any game version'
|
||||
const loaderLabel = preferences.loaders?.length ? preferences.loaders.join(', ') : 'any loader'
|
||||
|
||||
return new Error(
|
||||
contentType === 'datapack'
|
||||
? `No compatible version found for ${versionLabel}.`
|
||||
: `No compatible version found for ${versionLabel} / ${loaderLabel}.`,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,374 @@
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, onScopeDispose, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import type {
|
||||
EnvironmentSearchOverride,
|
||||
FilterType,
|
||||
FilterValue,
|
||||
ProjectType,
|
||||
SortType,
|
||||
Tags,
|
||||
} from '#ui/utils/search'
|
||||
import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search'
|
||||
import { useServerSearch } from '#ui/utils/server-search'
|
||||
|
||||
import type { BrowseDisplayMode, BrowseSearchResponse } from '../types'
|
||||
|
||||
export interface UseBrowseSearchOptions {
|
||||
projectType: Ref<string>
|
||||
tags: Ref<Tags>
|
||||
providedFilters?: ComputedRef<FilterValue[]>
|
||||
environmentOverride?: ComputedRef<EnvironmentSearchOverride | undefined>
|
||||
installContextLoader?: ComputedRef<string | undefined>
|
||||
search: (params: string, signal: AbortSignal) => Promise<BrowseSearchResponse>
|
||||
syncQueryParams?: boolean
|
||||
persistentQueryParams: string[]
|
||||
getExtraQueryParams?: () => Record<string, string | undefined>
|
||||
maxResultsOptions?: ComputedRef<number[]>
|
||||
displayMode?: Ref<BrowseDisplayMode> | ComputedRef<BrowseDisplayMode>
|
||||
initialSearchResponse?: BrowseSearchResponse
|
||||
}
|
||||
|
||||
export interface BrowseSearchState {
|
||||
query: Ref<string>
|
||||
|
||||
filters: ComputedRef<FilterType[]>
|
||||
currentFilters: Ref<FilterValue[]>
|
||||
toggledGroups: Ref<string[]>
|
||||
overriddenProvidedFilterTypes: Ref<string[]>
|
||||
|
||||
serverFilterTypes: ComputedRef<FilterType[]>
|
||||
serverCurrentFilters: Ref<FilterValue[]>
|
||||
serverToggledGroups: Ref<string[]>
|
||||
|
||||
effectiveSortTypes: ComputedRef<readonly SortType[]>
|
||||
effectiveCurrentSortType: Ref<SortType>
|
||||
|
||||
loading: Ref<boolean>
|
||||
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
|
||||
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
|
||||
totalHits: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
|
||||
maxResults: Ref<number>
|
||||
currentPage: Ref<number>
|
||||
|
||||
isServerType: ComputedRef<boolean>
|
||||
effectiveLayout: ComputedRef<'list' | 'compact' | 'grid'>
|
||||
deprioritizedTags: ComputedRef<string[]>
|
||||
excludeLoaders: ComputedRef<boolean>
|
||||
|
||||
refreshSearch: () => Promise<void>
|
||||
setPage: (page: number) => Promise<void>
|
||||
clearSearch: () => void
|
||||
onFilterChange: () => void
|
||||
}
|
||||
|
||||
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
|
||||
const debug = useDebugLogger('BrowseSearch')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
debug('init, projectType:', options.projectType.value)
|
||||
|
||||
const projectTypes = computed(() => [options.projectType.value] as ProjectType[])
|
||||
const isServerType = computed(() => options.projectType.value === 'server')
|
||||
|
||||
const {
|
||||
query,
|
||||
currentSortType,
|
||||
currentFilters,
|
||||
toggledGroups,
|
||||
maxResults,
|
||||
currentPage,
|
||||
overriddenProvidedFilterTypes,
|
||||
filters,
|
||||
sortTypes,
|
||||
requestParams,
|
||||
createPageParams,
|
||||
} = useSearch(
|
||||
projectTypes,
|
||||
options.tags,
|
||||
options.providedFilters ?? computed(() => []),
|
||||
options.environmentOverride ?? computed(() => undefined),
|
||||
)
|
||||
|
||||
const {
|
||||
serverCurrentSortType,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
serverSortTypes,
|
||||
serverFilterTypes,
|
||||
serverRequestParams,
|
||||
createServerPageParams,
|
||||
} = useServerSearch({
|
||||
tags: options.tags,
|
||||
query,
|
||||
maxResults,
|
||||
currentPage,
|
||||
providedFilters: options.providedFilters,
|
||||
})
|
||||
|
||||
const effectiveRequestParams = computed(() =>
|
||||
isServerType.value ? serverRequestParams.value : requestParams.value,
|
||||
)
|
||||
const effectiveSortTypes = computed(() =>
|
||||
isServerType.value ? (serverSortTypes as readonly SortType[]) : sortTypes,
|
||||
)
|
||||
const effectiveCurrentSortType = computed({
|
||||
get: () => (isServerType.value ? serverCurrentSortType.value : currentSortType.value),
|
||||
set: (v: SortType) => {
|
||||
if (isServerType.value) serverCurrentSortType.value = v
|
||||
else currentSortType.value = v
|
||||
},
|
||||
})
|
||||
|
||||
const effectiveMaxResultsOptions = computed(
|
||||
() => options.maxResultsOptions?.value ?? [5, 10, 15, 20, 50, 100],
|
||||
)
|
||||
|
||||
watch(effectiveMaxResultsOptions, (opts) => {
|
||||
if (!opts.includes(maxResults.value)) {
|
||||
maxResults.value = opts.reduce((prev, curr) =>
|
||||
Math.abs(curr - maxResults.value) <= Math.abs(prev - maxResults.value) ? curr : prev,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const effectiveDisplayMode = computed(() => options.displayMode?.value ?? 'list')
|
||||
const effectiveLayout = computed<'list' | 'compact' | 'grid'>(() =>
|
||||
effectiveDisplayMode.value === 'grid' || effectiveDisplayMode.value === 'gallery'
|
||||
? 'grid'
|
||||
: effectiveDisplayMode.value === 'compact'
|
||||
? 'compact'
|
||||
: 'list',
|
||||
)
|
||||
|
||||
const selectedFilterTags = computed(() =>
|
||||
currentFilters.value
|
||||
.filter(
|
||||
(f) =>
|
||||
f.type.startsWith('category_') ||
|
||||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
|
||||
)
|
||||
.map((f) => f.option),
|
||||
)
|
||||
const excludeLoaders = computed(
|
||||
() =>
|
||||
currentFilters.value.some((f) =>
|
||||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
|
||||
) ||
|
||||
!!options.installContextLoader?.value ||
|
||||
['resourcepack', 'datapack'].includes(options.projectType.value),
|
||||
)
|
||||
const loadersNotForThisType = computed(
|
||||
() =>
|
||||
options.tags.value?.loaders
|
||||
?.filter((loader) => !loader.supported_project_types.includes(options.projectType.value))
|
||||
?.map((loader) => loader.name) ?? [],
|
||||
)
|
||||
const deprioritizedTags = computed(() => [
|
||||
...selectedFilterTags.value,
|
||||
...loadersNotForThisType.value,
|
||||
])
|
||||
|
||||
const initialSearchResponse = options.initialSearchResponse
|
||||
const loading = ref(!initialSearchResponse)
|
||||
const projectHits = shallowRef<BrowseSearchResponse['projectHits']>(
|
||||
initialSearchResponse?.projectHits ?? [],
|
||||
)
|
||||
const serverHits = shallowRef<BrowseSearchResponse['serverHits']>(
|
||||
initialSearchResponse?.serverHits ?? [],
|
||||
)
|
||||
const totalHits = ref(initialSearchResponse?.total_hits ?? 0)
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (totalHits.value === 0) return 1
|
||||
return Math.ceil(totalHits.value / maxResults.value)
|
||||
})
|
||||
|
||||
let searchVersion = 0
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchAbortController: AbortController | null = null
|
||||
|
||||
const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? [])
|
||||
|
||||
watch(
|
||||
[
|
||||
query,
|
||||
maxResults,
|
||||
options.projectType,
|
||||
currentSortType,
|
||||
serverCurrentSortType,
|
||||
currentFilters,
|
||||
serverCurrentFilters,
|
||||
overriddenProvidedFilterTypes,
|
||||
providedFiltersOrEmpty,
|
||||
],
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(effectiveRequestParams, (newVal, oldVal) => {
|
||||
debug('effectiveRequestParams changed', {
|
||||
from: oldVal?.substring(0, 80),
|
||||
to: newVal?.substring(0, 80),
|
||||
})
|
||||
searchVersion++
|
||||
searchAbortController?.abort()
|
||||
searchAbortController = null
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
searchDebounceTimer = null
|
||||
refreshSearch()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
async function refreshSearch() {
|
||||
searchAbortController?.abort()
|
||||
const abortController = new AbortController()
|
||||
searchAbortController = abortController
|
||||
const version = ++searchVersion
|
||||
debug('refreshSearch start', {
|
||||
version,
|
||||
projectType: options.projectType.value,
|
||||
params: effectiveRequestParams.value.substring(0, 100),
|
||||
})
|
||||
|
||||
const currentHitsEmpty = isServerType.value
|
||||
? serverHits.value.length === 0
|
||||
: projectHits.value.length === 0
|
||||
if (currentHitsEmpty) {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await options.search(effectiveRequestParams.value, abortController.signal)
|
||||
|
||||
if (version !== searchVersion) {
|
||||
debug('refreshSearch stale, discarding', { version, current: searchVersion })
|
||||
return
|
||||
}
|
||||
|
||||
if (isServerType.value) {
|
||||
serverHits.value = response.serverHits
|
||||
} else {
|
||||
projectHits.value = response.projectHits
|
||||
}
|
||||
totalHits.value = response.total_hits
|
||||
if (currentPage.value > pageCount.value) {
|
||||
currentPage.value = pageCount.value
|
||||
}
|
||||
debug('refreshSearch complete', {
|
||||
version,
|
||||
hits: response.total_hits,
|
||||
projectHits: response.projectHits.length,
|
||||
serverHits: response.serverHits.length,
|
||||
})
|
||||
|
||||
if (options.syncQueryParams !== false) updateUrlParams()
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
debug('refreshSearch cancelled', { version })
|
||||
return
|
||||
}
|
||||
debug('refreshSearch error', err)
|
||||
console.error('Browse search error:', err)
|
||||
if (version === searchVersion) {
|
||||
loading.value = false
|
||||
}
|
||||
} finally {
|
||||
if (searchAbortController === abortController) {
|
||||
searchAbortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onScopeDispose(() => {
|
||||
searchVersion++
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
searchAbortController?.abort()
|
||||
})
|
||||
|
||||
function updateUrlParams() {
|
||||
debug('updateUrlParams', { path: route.path })
|
||||
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(route.query)) {
|
||||
if (options.persistentQueryParams.includes(key)) {
|
||||
persistentParams[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const extraParams = options.getExtraQueryParams?.() ?? {}
|
||||
for (const [key, value] of Object.entries(extraParams)) {
|
||||
persistentParams[key] = value
|
||||
}
|
||||
|
||||
const params = {
|
||||
...persistentParams,
|
||||
...(isServerType.value ? createServerPageParams() : createPageParams()),
|
||||
}
|
||||
|
||||
router.replace({ path: route.path, query: params })
|
||||
}
|
||||
|
||||
async function setPage(newPageNumber: number) {
|
||||
currentPage.value = newPageNumber
|
||||
await nextTick()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
query.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
nextTick(() => window.scrollTo({ top: 0, behavior: 'smooth' }))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => options.projectType.value,
|
||||
(newType, oldType) => {
|
||||
debug('projectType changed', { from: oldType, to: newType })
|
||||
effectiveCurrentSortType.value =
|
||||
effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ??
|
||||
effectiveSortTypes.value[0]
|
||||
query.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
query,
|
||||
filters,
|
||||
currentFilters,
|
||||
toggledGroups,
|
||||
overriddenProvidedFilterTypes,
|
||||
serverFilterTypes,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
effectiveSortTypes,
|
||||
effectiveCurrentSortType,
|
||||
loading,
|
||||
projectHits,
|
||||
serverHits,
|
||||
totalHits,
|
||||
pageCount,
|
||||
maxResults,
|
||||
currentPage,
|
||||
isServerType,
|
||||
effectiveLayout,
|
||||
deprioritizedTags,
|
||||
excludeLoaders,
|
||||
refreshSearch,
|
||||
setPage,
|
||||
clearSearch,
|
||||
onFilterChange,
|
||||
}
|
||||
}
|
||||
156
packages/ui/src/layouts/shared/browse-tab/header.vue
Normal file
156
packages/ui/src/layouts/shared/browse-tab/header.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { BoxIcon, getLoaderIcon, LeftArrowIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { useServerImage } from '#ui/composables/use-server-image'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import SelectedProjectsLeaveModal from './components/SelectedProjectsLeaveModal.vue'
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
import type { BrowseInstallContext } from './types'
|
||||
|
||||
const MEDAL_ICON_URL = 'https://cdn-raw.modrinth.com/medal_icon.webp'
|
||||
|
||||
const router = useRouter()
|
||||
const props = defineProps<{
|
||||
installContext?: BrowseInstallContext | null
|
||||
}>()
|
||||
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
|
||||
|
||||
const ctx = injectBrowseManager(null)
|
||||
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
|
||||
const selectedProjectsLeaveModal = ref<InstanceType<typeof SelectedProjectsLeaveModal>>()
|
||||
|
||||
const serverId = computed(() => installContext.value?.serverId ?? '')
|
||||
const upstream = computed(() => installContext.value?.upstream ?? null)
|
||||
|
||||
const { image: fetchedIcon } = useServerImage(serverId, upstream, {
|
||||
enabled: computed(() => !!installContext.value?.serverId),
|
||||
})
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
if (installContext.value?.isMedal) return MEDAL_ICON_URL
|
||||
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
|
||||
})
|
||||
|
||||
const loaderIcon = computed(() => {
|
||||
const loader = installContext.value?.loader
|
||||
return loader ? getLoaderIcon(loader) : undefined
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => installContext.value?.selectedProjects?.length ?? 0)
|
||||
const isInstallingSelected = computed(() => installContext.value?.isInstallingSelected ?? false)
|
||||
|
||||
async function handleBack() {
|
||||
const context = installContext.value
|
||||
if (!context) return
|
||||
|
||||
if (selectedCount.value > 0 && !isInstallingSelected.value) {
|
||||
if (context.skipNonEssentialWarnings) {
|
||||
await handleSelectedProjectsLeaveResult('discard', context)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await selectedProjectsLeaveModal.value?.prompt()
|
||||
await handleSelectedProjectsLeaveResult(result ?? 'cancel', context)
|
||||
return
|
||||
}
|
||||
|
||||
const shouldNavigate = await context.onBack?.()
|
||||
if (shouldNavigate === false) return
|
||||
|
||||
await router.push(context.backUrl)
|
||||
}
|
||||
|
||||
async function handleSelectedProjectsLeaveResult(
|
||||
result: SelectedProjectsLeaveResult,
|
||||
context: BrowseInstallContext,
|
||||
) {
|
||||
if (result === 'cancel') return
|
||||
if (result === 'install') {
|
||||
const shouldNavigate = await context.installSelected?.()
|
||||
if (shouldNavigate === false) return
|
||||
return
|
||||
}
|
||||
|
||||
if (context.discardSelectedAndBack) {
|
||||
await context.discardSelectedAndBack()
|
||||
return
|
||||
}
|
||||
|
||||
await (context.clearSelected ?? context.clearQueued)?.()
|
||||
await router.push(context.backUrl)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="installContext">
|
||||
<SelectedProjectsLeaveModal
|
||||
ref="selectedProjectsLeaveModal"
|
||||
:count="selectedCount"
|
||||
:installing="isInstallingSelected"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<ButtonStyled circular size="large">
|
||||
<button :aria-label="installContext.backLabel" @click="handleBack">
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<Avatar
|
||||
v-if="iconSrc"
|
||||
:src="iconSrc"
|
||||
size="48px"
|
||||
class="shrink-0"
|
||||
:class="{
|
||||
'!border-0 !rounded-none !bg-transparent !shadow-none': installContext.iconFrameless,
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-col justify-center gap-1">
|
||||
<h1 class="m-0 truncate text-2xl font-semibold leading-8 text-contrast">
|
||||
{{ installContext.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="installContext.heading || installContext.gameVersion || installContext.loader"
|
||||
class="flex flex-wrap items-center gap-2 text-base font-medium leading-6 text-primary"
|
||||
>
|
||||
<span v-if="installContext.heading">{{ installContext.heading }}</span>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
installContext.heading && (installContext.gameVersion || installContext.loader)
|
||||
"
|
||||
class="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"
|
||||
/>
|
||||
|
||||
<div v-if="installContext.gameVersion" class="flex items-center gap-1.5">
|
||||
<BoxIcon class="h-4 w-4" />
|
||||
{{ installContext.gameVersion }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="installContext.gameVersion && installContext.loader"
|
||||
class="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"
|
||||
/>
|
||||
|
||||
<div v-if="installContext.loader" class="flex items-center gap-1.5 capitalize">
|
||||
<component :is="loaderIcon" v-if="loaderIcon" class="h-4 w-4" />
|
||||
{{ formatLoaderLabel(installContext.loader) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
|
||||
{{ installContext.warning }}
|
||||
</Admonition>
|
||||
</template>
|
||||
</template>
|
||||
7
packages/ui/src/layouts/shared/browse-tab/index.ts
Normal file
7
packages/ui/src/layouts/shared/browse-tab/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as SelectedProjectsFloatingBar } from './components/SelectedProjectsFloatingBar.vue'
|
||||
export * from './composables'
|
||||
export { default as BrowseInstallHeader } from './header.vue'
|
||||
export { default as BrowsePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export { default as BrowseSidebar } from './sidebar.vue'
|
||||
export * from './types'
|
||||
410
packages/ui/src/layouts/shared/browse-tab/layout.vue
Normal file
410
packages/ui/src/layouts/shared/browse-tab/layout.vue
Normal file
@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
||||
import NavTabs from '#ui/components/base/NavTabs.vue'
|
||||
import Pagination from '#ui/components/base/Pagination.vue'
|
||||
import PopoutMenu from '#ui/components/base/PopoutMenu.vue'
|
||||
import ScrollToTopButton from '#ui/components/base/ScrollToTopButton.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
|
||||
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
|
||||
import SearchFilterControl from '#ui/components/search/SearchFilterControl.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useStickyObserver } from '#ui/composables/sticky-observer'
|
||||
import { commonMessages, formatProjectTypeSentence } from '#ui/utils/common-messages'
|
||||
import type { SortType } from '#ui/utils/search'
|
||||
|
||||
import SelectedProjectsFloatingBar from './components/SelectedProjectsFloatingBar.vue'
|
||||
import BrowseInstallHeader from './header.vue'
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||
stickyInstallHeaderRef,
|
||||
'BrowseInstallHeader',
|
||||
)
|
||||
|
||||
const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
(ctx.maxResultsOptions?.value ?? [5, 10, 15, 20, 50, 100]).map((n) => ({
|
||||
value: n,
|
||||
label: String(n),
|
||||
})),
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'browse.search.placeholder',
|
||||
defaultMessage: 'Search {projectType}...',
|
||||
},
|
||||
viewPrefix: {
|
||||
id: 'browse.view-prefix',
|
||||
defaultMessage: 'View:',
|
||||
},
|
||||
filterResults: {
|
||||
id: 'browse.filter-results',
|
||||
defaultMessage: 'Filter results...',
|
||||
},
|
||||
offline: {
|
||||
id: 'browse.offline',
|
||||
defaultMessage: 'You are currently offline. Connect to the internet to browse Modrinth!',
|
||||
},
|
||||
noResults: {
|
||||
id: 'browse.no-results',
|
||||
defaultMessage: 'No results found for your query!',
|
||||
},
|
||||
sortRelevance: { id: 'browse.sort.relevance', defaultMessage: 'Relevance' },
|
||||
sortDownloads: { id: 'browse.sort.downloads', defaultMessage: 'Downloads' },
|
||||
sortFollowers: { id: 'browse.sort.followers', defaultMessage: 'Followers' },
|
||||
sortDatePublished: { id: 'browse.sort.date-published', defaultMessage: 'Date published' },
|
||||
sortDateUpdated: { id: 'browse.sort.date-updated', defaultMessage: 'Date updated' },
|
||||
sortVerifiedPlays: { id: 'browse.sort.verified-plays', defaultMessage: 'Verified plays' },
|
||||
sortPlayers: { id: 'browse.sort.players', defaultMessage: 'Players' },
|
||||
})
|
||||
|
||||
function formatSortType(sortType: SortType): string {
|
||||
const sortMessages = {
|
||||
relevance: messages.sortRelevance,
|
||||
downloads: messages.sortDownloads,
|
||||
follows: messages.sortFollowers,
|
||||
newest: messages.sortDatePublished,
|
||||
updated: messages.sortDateUpdated,
|
||||
'minecraft_java_server.verified_plays_2w': messages.sortVerifiedPlays,
|
||||
'minecraft_java_server.ping.data.players_online': messages.sortPlayers,
|
||||
date_created: messages.sortDatePublished,
|
||||
date_modified: messages.sortDateUpdated,
|
||||
}
|
||||
|
||||
const message = sortMessages[sortType.name as keyof typeof sortMessages]
|
||||
return message ? formatMessage(message) : sortType.display
|
||||
}
|
||||
|
||||
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
|
||||
ctx.effectiveSortTypes.value.map((sortType) => ({
|
||||
value: sortType,
|
||||
label: formatSortType(sortType),
|
||||
})),
|
||||
)
|
||||
|
||||
const selectedDisplayMode = computed(() =>
|
||||
ctx.displayModeOptions?.value.find((option) => option.id === ctx.displayMode?.value),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template
|
||||
v-if="
|
||||
ctx.installContext?.value &&
|
||||
ctx.installContext.value.showInstallHeader !== false &&
|
||||
ctx.variant !== 'web'
|
||||
"
|
||||
>
|
||||
<div
|
||||
ref="stickyInstallHeaderRef"
|
||||
class="browse-install-header sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
|
||||
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
||||
>
|
||||
<BrowseInstallHeader />
|
||||
</div>
|
||||
</template>
|
||||
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value && ctx.variant !== 'web'" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
|
||||
<slot name="nav-tabs-actions" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="ctx.query.value"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
projectType: formatProjectTypeSentence(formatMessage, ctx.projectType.value, 2),
|
||||
})
|
||||
"
|
||||
clearable
|
||||
wrapper-class="flex-1"
|
||||
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
|
||||
@clear="ctx.clearSearch()"
|
||||
/>
|
||||
<slot name="search-bar-actions" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Combobox
|
||||
:model-value="ctx.effectiveCurrentSortType.value"
|
||||
:options="sortOptions"
|
||||
:class="
|
||||
ctx.variant === 'web'
|
||||
? '!w-[16rem] min-w-max max-w-full flex-grow md:flex-grow-0'
|
||||
: '!w-[16rem] min-w-max max-w-full'
|
||||
"
|
||||
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="font-semibold text-primary">{{
|
||||
formatMessage(commonMessages.sortByLabel)
|
||||
}}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<Combobox
|
||||
:model-value="ctx.maxResults.value"
|
||||
:options="maxResultsOptions"
|
||||
:class="
|
||||
ctx.variant === 'web'
|
||||
? '!w-[9rem] min-w-max max-w-full flex-grow md:flex-grow-0'
|
||||
: '!w-[9rem] min-w-max max-w-full'
|
||||
"
|
||||
:placeholder="formatMessage(commonMessages.viewLabel)"
|
||||
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="font-semibold text-primary">{{ formatMessage(messages.viewPrefix) }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
|
||||
<ButtonStyled>
|
||||
<button @click="ctx.filtersMenuOpen.value = true">
|
||||
{{ formatMessage(messages.filterResults) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<PopoutMenu
|
||||
v-if="ctx.displayMode && ctx.displayModeOptions?.value.length && ctx.setDisplayMode"
|
||||
:tooltip="ctx.displayModeTooltip?.value"
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ButtonStyled circular>
|
||||
<button :aria-label="ctx.displayModeTooltip?.value">
|
||||
<component :is="selectedDisplayMode?.icon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #menu>
|
||||
<div class="flex w-44 flex-col gap-1 p-1">
|
||||
<ButtonStyled
|
||||
v-for="option in ctx.displayModeOptions.value"
|
||||
:key="option.id"
|
||||
:type="ctx.displayMode.value === option.id ? 'filled' : 'transparent'"
|
||||
>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 !justify-start text-left"
|
||||
:aria-pressed="ctx.displayMode.value === option.id"
|
||||
@click="ctx.setDisplayMode!(option.id)"
|
||||
>
|
||||
<component :is="option.icon" class="h-4 w-4" />
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
|
||||
<Pagination
|
||||
:page="ctx.currentPage.value"
|
||||
:count="ctx.pageCount.value"
|
||||
:class="ctx.variant === 'web' ? 'mx-auto sm:ml-auto sm:mr-0' : 'ml-auto'"
|
||||
@switch-page="ctx.setPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchFilterControl
|
||||
v-if="ctx.isServerType.value"
|
||||
v-model:selected-filters="ctx.serverCurrentFilters.value"
|
||||
:filters="ctx.serverFilterTypes.value"
|
||||
:provided-filters="[]"
|
||||
:overridden-provided-filter-types="[]"
|
||||
:project-type="ctx.projectType.value"
|
||||
/>
|
||||
<SearchFilterControl
|
||||
v-else
|
||||
v-model:selected-filters="ctx.currentFilters.value"
|
||||
:filters="
|
||||
ctx.filters.value.filter(
|
||||
(f) => f.display !== 'none' && !(ctx.hiddenFilterTypes?.value ?? []).includes(f.id),
|
||||
)
|
||||
"
|
||||
:provided-filters="ctx.providedFilters?.value ?? []"
|
||||
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
|
||||
:project-type="ctx.projectType.value"
|
||||
:provided-message="lockedMessages?.providedBy"
|
||||
/>
|
||||
|
||||
<slot name="above-results" />
|
||||
|
||||
<div class="search">
|
||||
<section v-if="ctx.loading.value" class="offline">
|
||||
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
|
||||
</section>
|
||||
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
|
||||
{{ formatMessage(messages.offline) }}
|
||||
</section>
|
||||
<section
|
||||
v-else-if="
|
||||
ctx.isServerType.value
|
||||
? ctx.serverHits.value.length === 0
|
||||
: ctx.projectHits.value.length === 0
|
||||
"
|
||||
class="offline"
|
||||
>
|
||||
<p>{{ formatMessage(messages.noResults) }}</p>
|
||||
</section>
|
||||
|
||||
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">
|
||||
<template v-if="ctx.isServerType.value">
|
||||
<ProjectCard
|
||||
v-for="result in ctx.serverHits.value"
|
||||
:key="`server-card-${result.project_id}`"
|
||||
:title="result.name"
|
||||
:icon-url="result.icon_url || undefined"
|
||||
:summary="result.summary"
|
||||
:tags="result.categories"
|
||||
:link="ctx.getServerProjectLink(result)"
|
||||
:server-online-players="result.minecraft_java_server?.ping?.data?.players_online ?? 0"
|
||||
:server-region="result.minecraft_server?.region"
|
||||
:server-recent-plays="result.minecraft_java_server?.verified_plays_2w ?? 0"
|
||||
:server-modpack-content="ctx.getServerModpackContent?.(result)"
|
||||
:server-ping="ctx.serverPings?.value?.[result.project_id]"
|
||||
:server-status-online="!!result.minecraft_java_server?.ping?.data"
|
||||
:hide-online-players-label="ctx.variant === 'app'"
|
||||
:hide-recent-plays-label="ctx.variant === 'app'"
|
||||
:layout="ctx.effectiveLayout.value"
|
||||
:max-tags="2"
|
||||
is-server-project
|
||||
exclude-loaders
|
||||
:color="result.color ?? undefined"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
|
||||
@mouseenter="ctx.onServerProjectHover?.(result)"
|
||||
@mouseleave="ctx.onProjectHoverEnd?.()"
|
||||
>
|
||||
<template v-if="ctx.getCardActions?.(result, ctx.projectType.value)?.length" #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
|
||||
:key="action.key"
|
||||
:color="action.color"
|
||||
:type="action.type"
|
||||
:size="ctx.effectiveLayout.value === 'compact' ? 'small' : 'standard'"
|
||||
:circular="action.circular"
|
||||
>
|
||||
<button
|
||||
v-tooltip="action.tooltip"
|
||||
:disabled="action.disabled"
|
||||
@click.stop="action.onClick"
|
||||
>
|
||||
<component :is="action.icon" :class="action.iconClass" />
|
||||
<template v-if="!action.circular">{{
|
||||
ctx.effectiveLayout.value === 'compact'
|
||||
? (action.compactLabel ?? action.label)
|
||||
: action.label
|
||||
}}</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ProjectCard
|
||||
v-for="result in ctx.projectHits.value"
|
||||
:key="`${result.provider}:${result.project_id}`"
|
||||
:link="ctx.getProjectLink(result)"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
:author="{
|
||||
name: result.organization == null ? result.author : result.organization,
|
||||
link:
|
||||
result.provider === 'curseforge'
|
||||
? result.author_url
|
||||
: result.provider === 'modrinth'
|
||||
? result.organization_id == null
|
||||
? ctx.variant === 'web'
|
||||
? `/user/${result.author_id ?? result.author}`
|
||||
: `https://modrinth.com/user/${result.author_id ?? result.author}`
|
||||
: ctx.variant === 'web'
|
||||
? `/organization/${result.organization_id}`
|
||||
: `https://modrinth.com/organization/${result.organization_id}`
|
||||
: undefined,
|
||||
}"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="
|
||||
ctx.effectiveCurrentSortType.value.name === 'newest' ? 'published' : 'updated'
|
||||
"
|
||||
:downloads="result.downloads"
|
||||
:summary="result.description"
|
||||
:tags="result.display_categories"
|
||||
:all-tags="result.categories"
|
||||
:deprioritized-tags="ctx.deprioritizedTags.value"
|
||||
:exclude-loaders="ctx.excludeLoaders.value"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
:color="result.color ?? undefined"
|
||||
:provider="result.provider"
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(ctx.projectType.value)
|
||||
? {
|
||||
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
|
||||
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:layout="ctx.effectiveLayout.value"
|
||||
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
|
||||
@mouseenter="ctx.onProjectHover?.(result)"
|
||||
@mouseleave="ctx.onProjectHoverEnd?.()"
|
||||
>
|
||||
<template v-if="ctx.getCardActions?.(result, ctx.projectType.value)?.length" #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
|
||||
:key="action.key"
|
||||
:color="action.color"
|
||||
:type="action.type"
|
||||
:size="ctx.effectiveLayout.value === 'compact' ? 'small' : 'standard'"
|
||||
:circular="action.circular"
|
||||
>
|
||||
<button
|
||||
v-tooltip="action.tooltip"
|
||||
:disabled="action.disabled"
|
||||
@click.stop="action.onClick"
|
||||
>
|
||||
<component :is="action.icon" :class="action.iconClass" />
|
||||
<template v-if="!action.circular">{{
|
||||
ctx.effectiveLayout.value === 'compact'
|
||||
? (action.compactLabel ?? action.label)
|
||||
: action.label
|
||||
}}</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
</ProjectCardList>
|
||||
|
||||
<div :class="ctx.variant === 'web' ? 'pagination-after mt-3' : 'flex justify-end mt-3'">
|
||||
<Pagination
|
||||
:page="ctx.currentPage.value"
|
||||
:count="ctx.pageCount.value"
|
||||
:class="ctx.variant === 'web' ? 'justify-end' : 'pagination-after'"
|
||||
@switch-page="ctx.setPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="after" />
|
||||
<ScrollToTopButton />
|
||||
</template>
|
||||
@ -0,0 +1,116 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Component, ComputedRef, MaybeRef, Ref, ShallowRef } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
import type { FilterType, FilterValue, SortType, Tags } from '#ui/utils/search'
|
||||
|
||||
import type {
|
||||
BrowseDisplayMode,
|
||||
BrowseDisplayModeOption,
|
||||
BrowseInstallContext,
|
||||
BrowseSearchResponse,
|
||||
CardAction,
|
||||
ServerModpackContent,
|
||||
} from '../types'
|
||||
|
||||
export interface BrowseManagerContext {
|
||||
tags: Ref<Tags>
|
||||
projectType: Ref<string>
|
||||
|
||||
query: Ref<string>
|
||||
filters: ComputedRef<FilterType[]>
|
||||
currentFilters: Ref<FilterValue[]>
|
||||
toggledGroups: Ref<string[]>
|
||||
overriddenProvidedFilterTypes: Ref<string[]>
|
||||
serverFilterTypes: ComputedRef<FilterType[]>
|
||||
serverCurrentFilters: Ref<FilterValue[]>
|
||||
serverToggledGroups: Ref<string[]>
|
||||
effectiveSortTypes: ComputedRef<readonly SortType[]>
|
||||
effectiveCurrentSortType: Ref<SortType>
|
||||
loading: Ref<boolean>
|
||||
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
|
||||
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
|
||||
totalHits: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
maxResults: Ref<number>
|
||||
currentPage: Ref<number>
|
||||
isServerType: ComputedRef<boolean>
|
||||
effectiveLayout: ComputedRef<'list' | 'grid'>
|
||||
deprioritizedTags: ComputedRef<string[]>
|
||||
excludeLoaders: ComputedRef<boolean>
|
||||
refreshSearch: () => Promise<void>
|
||||
setPage: (page: number) => Promise<void>
|
||||
clearSearch: () => void
|
||||
onFilterChange: () => void
|
||||
|
||||
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => string | RouteLocationRaw
|
||||
getServerProjectLink: (
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
) => string | RouteLocationRaw
|
||||
|
||||
selectableProjectTypes: ComputedRef<
|
||||
{ label: string; href: string; shown?: boolean; onboardingId?: string }[]
|
||||
>
|
||||
showProjectTypeTabs: ComputedRef<boolean>
|
||||
|
||||
variant: 'app' | 'web'
|
||||
|
||||
getCardActions?: (
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
projectType: string,
|
||||
) => CardAction[]
|
||||
|
||||
installContext?: ComputedRef<BrowseInstallContext | null>
|
||||
providedFilters?: ComputedRef<FilterValue[]>
|
||||
hideInstalled?: Ref<boolean>
|
||||
showHideInstalled?: ComputedRef<boolean>
|
||||
hideInstalledLabel?: ComputedRef<string>
|
||||
hideSelected?: Ref<boolean>
|
||||
showHideSelected?: ComputedRef<boolean>
|
||||
hideSelectedLabel?: ComputedRef<string>
|
||||
serverOnly?: Ref<boolean>
|
||||
showServerOnly?: ComputedRef<boolean>
|
||||
serverOnlyLabel?: ComputedRef<string>
|
||||
hiddenFilterTypes?: ComputedRef<string[]>
|
||||
advancedFiltersCollapsed?: Ref<boolean>
|
||||
onInstalled?: (projectId: string) => void
|
||||
|
||||
displayMode?: Ref<BrowseDisplayMode> | ComputedRef<BrowseDisplayMode>
|
||||
displayModeOptions?: ComputedRef<BrowseDisplayModeOption[]>
|
||||
displayModeTooltip?: ComputedRef<string>
|
||||
setDisplayMode?: (mode: BrowseDisplayMode) => void
|
||||
maxResultsOptions?: ComputedRef<number[]>
|
||||
|
||||
serverPings?: Ref<Record<string, number | undefined>>
|
||||
getServerModpackContent?: (
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
) => ServerModpackContent | undefined
|
||||
|
||||
onProjectHover?: (result: Labrinth.Search.v2.ResultSearchProject) => void
|
||||
onServerProjectHover?: (result: Labrinth.Search.v3.ResultSearchProject) => void
|
||||
onProjectHoverEnd?: () => void
|
||||
onContextMenu?: (
|
||||
event: MouseEvent,
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
) => void
|
||||
offline?: Ref<boolean>
|
||||
|
||||
filtersMenuOpen?: Ref<boolean>
|
||||
|
||||
lockedFilterMessages?: MaybeRef<{
|
||||
gameVersion?: string
|
||||
modLoader?: string
|
||||
environment?: string
|
||||
syncButton?: string
|
||||
providedBy?: string
|
||||
gameVersionShaderMessage?: string
|
||||
}>
|
||||
|
||||
loadingComponent?: Component
|
||||
}
|
||||
|
||||
export const [injectBrowseManager, provideBrowseManager] = createContext<BrowseManagerContext>(
|
||||
'BrowsePageLayout',
|
||||
'browseManagerContext',
|
||||
)
|
||||
@ -0,0 +1 @@
|
||||
export * from './browse-manager'
|
||||
244
packages/ui/src/layouts/shared/browse-tab/sidebar.vue
Normal file
244
packages/ui/src/layouts/shared/browse-tab/sidebar.vue
Normal file
@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Toggle from '#ui/components/base/Toggle.vue'
|
||||
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const isApp = computed(() => ctx.variant === 'app')
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
|
||||
|
||||
const advancedFiltersCollapsed = computed(() => ctx.advancedFiltersCollapsed?.value ?? true)
|
||||
|
||||
function setAdvancedFiltersCollapsed(collapsed: boolean) {
|
||||
if (ctx.advancedFiltersCollapsed) {
|
||||
ctx.advancedFiltersCollapsed.value = collapsed
|
||||
}
|
||||
}
|
||||
|
||||
function closeFiltersMenu() {
|
||||
if (ctx.filtersMenuOpen) {
|
||||
ctx.filtersMenuOpen.value = false
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior })
|
||||
}
|
||||
|
||||
const filterClass = computed(() => {
|
||||
if (isApp.value) {
|
||||
return 'border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
|
||||
}
|
||||
if (ctx.filtersMenuOpen?.value) {
|
||||
return 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
}
|
||||
return 'card-shadow rounded-2xl bg-surface-3 border border-solid border-surface-4'
|
||||
})
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
if (isApp.value) {
|
||||
return 'button-animation flex flex-col gap-1 px-3 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg'
|
||||
}
|
||||
return 'button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none'
|
||||
})
|
||||
|
||||
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
|
||||
const innerPanelClass = computed(() => (isApp.value ? 'ml-2 mr-3' : 'p-1'))
|
||||
|
||||
function hasProvidedFilter(filterId: string): boolean {
|
||||
return (ctx.providedFilters?.value ?? []).some((filter) => filter.type === filterId)
|
||||
}
|
||||
|
||||
function getFilterOpenByDefault(filterId: string): boolean {
|
||||
if (filterId === 'advanced') {
|
||||
return !advancedFiltersCollapsed.value
|
||||
}
|
||||
if (hasProvidedFilter(filterId)) {
|
||||
return true
|
||||
}
|
||||
if (filterId === 'compatible_dependency_project_ids') {
|
||||
return true
|
||||
}
|
||||
if (ctx.isServerType.value) {
|
||||
return ![
|
||||
'server_category_minecraft_server_meta',
|
||||
'server_category_minecraft_server_community',
|
||||
'server_game_version',
|
||||
'server_status',
|
||||
].includes(filterId)
|
||||
}
|
||||
if (isApp.value) {
|
||||
if (filterId.includes('cf-extra')) return false
|
||||
return filterId.startsWith('category') || filterId === 'environment' || filterId === 'license'
|
||||
}
|
||||
if (
|
||||
lockedMessages.value?.gameVersionShaderMessage &&
|
||||
ctx.projectType.value === 'shader' &&
|
||||
filterId === 'game_version'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot name="prepend" />
|
||||
|
||||
<div v-if="ctx.filtersMenuOpen?.value" class="fixed inset-0 z-40 bg-bg" />
|
||||
|
||||
<div
|
||||
class="flex flex-col"
|
||||
:class="{
|
||||
'gap-3': !isApp,
|
||||
'fixed inset-0 z-50 m-4 mb-0 overflow-auto rounded-t-3xl bg-bg-raised':
|
||||
ctx.filtersMenuOpen?.value,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="ctx.filtersMenuOpen?.value"
|
||||
class="sticky top-0 z-10 mx-1 flex items-center justify-between gap-3 border-0 border-b-[1px] border-solid border-divider bg-bg-raised px-6 py-4"
|
||||
>
|
||||
<h3 class="m-0 text-lg text-contrast">{{ formatMessage(commonMessages.filtersLabel) }}</h3>
|
||||
<ButtonStyled circular>
|
||||
<button @click="closeFiltersMenu">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
ctx.showHideInstalled?.value || ctx.showHideSelected?.value || ctx.showServerOnly?.value
|
||||
"
|
||||
:class="
|
||||
isApp
|
||||
? 'flex flex-col gap-3 border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
|
||||
: 'card-shadow flex flex-col gap-3 rounded-2xl bg-bg-raised border-solid border-surface-4 border p-4'
|
||||
"
|
||||
>
|
||||
<label
|
||||
v-if="ctx.showServerOnly?.value"
|
||||
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
|
||||
>
|
||||
{{ ctx.serverOnlyLabel?.value ?? formatMessage(commonMessages.serverOnlyLabel) }}
|
||||
<Toggle
|
||||
v-model="ctx.serverOnly!.value"
|
||||
small
|
||||
class="shrink-0"
|
||||
@update:model-value="ctx.onFilterChange()"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="ctx.showHideInstalled?.value"
|
||||
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
|
||||
>
|
||||
{{
|
||||
ctx.hideInstalledLabel?.value ?? formatMessage(commonMessages.hideInstalledContentLabel)
|
||||
}}
|
||||
<Toggle
|
||||
v-model="ctx.hideInstalled!.value"
|
||||
small
|
||||
class="shrink-0"
|
||||
@update:model-value="ctx.onFilterChange()"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="ctx.showHideSelected?.value"
|
||||
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
|
||||
>
|
||||
{{ ctx.hideSelectedLabel?.value ?? formatMessage(commonMessages.hideSelectedContentLabel) }}
|
||||
<Toggle
|
||||
v-model="ctx.hideSelected!.value"
|
||||
small
|
||||
class="shrink-0"
|
||||
@update:model-value="ctx.onFilterChange()"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="ctx.isServerType.value">
|
||||
<SearchSidebarFilter
|
||||
v-for="filterType in ctx.serverFilterTypes.value.filter(
|
||||
(f) => f.options.length > 0 && !hiddenFilterTypes.includes(f.id),
|
||||
)"
|
||||
:key="`server-filter-${filterType.id}`"
|
||||
v-model:selected-filters="ctx.serverCurrentFilters.value"
|
||||
v-model:toggled-groups="ctx.serverToggledGroups.value"
|
||||
:provided-filters="[]"
|
||||
:filter-type="filterType"
|
||||
:project-type="ctx.projectType.value"
|
||||
:class="filterClass"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:open-by-default="getFilterOpenByDefault(filterType.id)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-base font-semibold'">
|
||||
{{ filterType.formatted_name }}
|
||||
</h3>
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in ctx.filters.value.filter(
|
||||
(f) => f.display !== 'none' && !hiddenFilterTypes.includes(f.id),
|
||||
)"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="ctx.currentFilters.value"
|
||||
v-model:toggled-groups="ctx.toggledGroups.value"
|
||||
v-model:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
|
||||
:provided-filters="ctx.providedFilters?.value ?? []"
|
||||
:filter-type="filter"
|
||||
:project-type="ctx.projectType.value"
|
||||
:class="filterClass"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:open-by-default="getFilterOpenByDefault(filter.id)"
|
||||
@on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
|
||||
@on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg font-semibold'">
|
||||
{{ filter.formatted_name }}
|
||||
</h3>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
lockedMessages?.gameVersionShaderMessage &&
|
||||
ctx.projectType.value === 'shader' &&
|
||||
filter.id === 'game_version'
|
||||
"
|
||||
#prefix
|
||||
>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span>{{ lockedMessages.gameVersionShaderMessage }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="lockedMessages?.gameVersion" #locked-game_version>
|
||||
{{ lockedMessages.gameVersion }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.modLoader" #locked-mod_loader>
|
||||
{{ lockedMessages.modLoader }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.environment" #locked-environment>
|
||||
{{ lockedMessages.environment }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.syncButton" #sync-button>
|
||||
{{ lockedMessages.syncButton }}
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
83
packages/ui/src/layouts/shared/browse-tab/types.ts
Normal file
83
packages/ui/src/layouts/shared/browse-tab/types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Component } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export type BrowseDisplayMode = 'list' | 'compact' | 'grid' | 'gallery'
|
||||
|
||||
export interface BrowseDisplayModeOption {
|
||||
id: BrowseDisplayMode
|
||||
label: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
export interface BrowseSearchResponse {
|
||||
projectHits: (Labrinth.Search.v2.ResultSearchProject & {
|
||||
installed?: boolean
|
||||
installing?: boolean
|
||||
provider: 'modrinth' | 'curseforge' | 'mcarchive' | 'planet_minecraft'
|
||||
provider_project_id?: string
|
||||
author_url?: string
|
||||
})[]
|
||||
serverHits: Labrinth.Search.v3.ResultSearchProject[]
|
||||
total_hits: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface BrowseSelectedProject {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl?: string | null
|
||||
}
|
||||
|
||||
export interface BrowseInstallContext {
|
||||
showInstallHeader?: boolean
|
||||
name: string
|
||||
loader: string
|
||||
gameVersion: string
|
||||
serverId?: string | null
|
||||
upstream?: { project_id?: string | null } | null
|
||||
iconSrc?: string | null
|
||||
iconFrameless?: boolean
|
||||
isMedal?: boolean
|
||||
backUrl: string | RouteLocationRaw
|
||||
backLabel: string
|
||||
heading: string
|
||||
warning?: string
|
||||
queuedCount?: number
|
||||
queuedLabel?: string
|
||||
clearQueued?: () => void | Promise<void>
|
||||
onBack?: () => boolean | void | Promise<boolean | void>
|
||||
selectedProjects?: BrowseSelectedProject[]
|
||||
isInstallingSelected?: boolean
|
||||
skipNonEssentialWarnings?: boolean
|
||||
installProgress?: {
|
||||
completed: number
|
||||
total: number
|
||||
}
|
||||
installButtonLabel?: string
|
||||
processingLabel?: string
|
||||
clearSelected?: () => void | Promise<void>
|
||||
discardSelectedAndBack?: () => void | Promise<void>
|
||||
installSelected?: () => boolean | void | Promise<boolean | void>
|
||||
}
|
||||
|
||||
export interface CardAction {
|
||||
key: string
|
||||
label: string
|
||||
compactLabel?: string
|
||||
icon: Component
|
||||
iconClass?: string
|
||||
disabled?: boolean
|
||||
color?: 'brand' | 'red' | 'green'
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
circular?: boolean
|
||||
tooltip?: string
|
||||
onClick: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface ServerModpackContent {
|
||||
name: string
|
||||
icon?: string
|
||||
onclick?: () => void
|
||||
showCustomModpackTooltip: boolean
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<ButtonStyled v-if="showClear && hasLogs" type="transparent">
|
||||
<button
|
||||
v-tooltip="clearDisabled ? clearDisabledTooltip : undefined"
|
||||
:disabled="clearDisabled"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.clearButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="showDelete" type="transparent" hover-color-fill="background" color="red">
|
||||
<button
|
||||
v-tooltip="deleteDisabled ? deleteDisabledTooltip : undefined"
|
||||
:disabled="deleteDisabled"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="hasLogs" type="transparent">
|
||||
<button
|
||||
v-tooltip="shareDisabled ? shareDisabledTooltip : undefined"
|
||||
:disabled="shareDisabled || sharing"
|
||||
@click="emit('share')"
|
||||
>
|
||||
<SpinnerIcon v-if="sharing" class="animate-spin" />
|
||||
<ShareIcon v-else />
|
||||
{{ formatMessage(messages.share) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="emit('toggle-fullscreen')">
|
||||
<ContractIcon v-if="fullscreen" />
|
||||
<ExpandIcon v-else />
|
||||
{{ formatMessage(fullscreen ? messages.collapse : messages.expand) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ContractIcon,
|
||||
ExpandIcon,
|
||||
ShareIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
defineProps<{
|
||||
showClear?: boolean
|
||||
hasLogs?: boolean
|
||||
shareDisabled?: boolean
|
||||
shareDisabledTooltip?: string
|
||||
sharing?: boolean
|
||||
fullscreen?: boolean
|
||||
clearDisabled?: boolean
|
||||
clearDisabledTooltip?: string
|
||||
showDelete?: boolean
|
||||
deleteDisabled?: boolean
|
||||
deleteDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
share: []
|
||||
'toggle-fullscreen': []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
share: { id: 'console.action.share', defaultMessage: 'Share' },
|
||||
expand: { id: 'console.action.expand', defaultMessage: 'Expand' },
|
||||
collapse: { id: 'console.action.collapse', defaultMessage: 'Collapse' },
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||
:class="
|
||||
modelValue.has(option.id)
|
||||
? 'bg-brand-highlight text-brand'
|
||||
: 'bg-surface-4 text-primary hover:bg-surface-5'
|
||||
"
|
||||
:aria-pressed="modelValue.has(option.id)"
|
||||
@click="handleToggle(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
type FilterValue = LogLevel
|
||||
|
||||
const modelValue = defineModel<Set<FilterValue>>({ required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [value: FilterValue]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
error: { id: 'console.filter.error', defaultMessage: 'Error' },
|
||||
warn: { id: 'console.filter.warn', defaultMessage: 'Warn' },
|
||||
info: { id: 'console.filter.info', defaultMessage: 'Info' },
|
||||
})
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ id: 'error' as const, message: messages.error },
|
||||
{ id: 'warn' as const, message: messages.warn },
|
||||
{ id: 'info' as const, message: messages.info },
|
||||
]
|
||||
|
||||
const filterOptions = computed(() =>
|
||||
FILTER_OPTIONS.map((option) => ({ id: option.id, label: formatMessage(option.message) })),
|
||||
)
|
||||
|
||||
function handleToggle(id: FilterValue) {
|
||||
emit('toggle', id)
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,681 @@
|
||||
<template>
|
||||
<div ref="root" class="relative w-full font-mono text-base">
|
||||
<StyledInput
|
||||
v-if="!enhanced"
|
||||
v-model="fallbackValue"
|
||||
:icon="TerminalSquareIcon"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
wrapper-class="w-full"
|
||||
input-class="!h-9"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
@keydown.enter="submitFallback"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="relative min-h-9 overflow-hidden rounded-xl bg-surface-4 pl-10 pr-3 ring-brand-shadow focus-within:ring-4"
|
||||
>
|
||||
<TerminalSquareIcon
|
||||
class="pointer-events-none absolute left-3 top-2 h-5 w-5 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none min-h-9 whitespace-pre-wrap break-all py-2 font-medium leading-5 text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span v-for="(segment, index) in styledInput" :key="index" :style="segment.style">{{
|
||||
segment.text
|
||||
}}</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref="input"
|
||||
:value="prompt?.value ?? ''"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
rows="1"
|
||||
class="absolute inset-0 h-full min-h-9 w-full resize-none overflow-hidden border-0 bg-transparent py-2 pl-10 pr-3 font-mono font-medium leading-5 text-transparent caret-[var(--color-text-default)] outline-none placeholder:text-secondary"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
:spellcheck="false"
|
||||
@keydown="handleKeydown"
|
||||
@click="syncPointerCursor"
|
||||
@pointerdown="pointerSelecting = true"
|
||||
@beforeinput="handleBeforeInput"
|
||||
@paste="handlePaste"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="candidates.length"
|
||||
class="absolute bottom-full left-0 z-20 mb-2 flex w-full min-w-64 max-w-lg flex-col overflow-hidden rounded-lg border border-solid border-surface-4 bg-surface-3 shadow-lg"
|
||||
>
|
||||
<div class="border-0 border-b border-solid border-surface-4 p-2">
|
||||
<StyledInput
|
||||
v-model="candidateSearchQuery"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchCompletions)"
|
||||
wrapper-class="w-full"
|
||||
input-class="!h-9 font-sans"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
clearable
|
||||
@keydown.down.prevent="moveCandidateFocus(1)"
|
||||
@keydown.up.prevent="moveCandidateFocus(-1)"
|
||||
@keydown.tab.prevent="moveCandidateFocus($event.shiftKey ? -1 : 1)"
|
||||
@keydown.enter.prevent="selectFocusedCandidate"
|
||||
@keydown.escape.prevent="closeCandidateSearch"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredCandidates.length"
|
||||
role="listbox"
|
||||
class="flex max-h-56 flex-col gap-1 overflow-y-auto overscroll-contain p-2"
|
||||
>
|
||||
<button
|
||||
v-for="(candidate, index) in filteredCandidates"
|
||||
:key="`${candidate.row}:${candidate.column}:${candidate.text}`"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="index === focusedCandidateIndex"
|
||||
:data-completion-focused="index === focusedCandidateIndex"
|
||||
class="min-h-9 min-w-0 shrink-0 rounded-md border-0 px-3 py-2 text-left font-mono text-sm transition-colors hover:bg-surface-4"
|
||||
:class="
|
||||
index === focusedCandidateIndex
|
||||
? 'bg-surface-4 text-contrast'
|
||||
: 'bg-transparent text-primary'
|
||||
"
|
||||
:style="candidateStyleToCss(candidate, index === focusedCandidateIndex)"
|
||||
@mouseenter="focusedCandidateIndex = index"
|
||||
@mousedown.prevent
|
||||
@click="selectCandidate(candidate)"
|
||||
>
|
||||
<span class="block truncate">{{ candidate.text }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="px-3 py-4 text-center font-sans text-sm text-secondary">
|
||||
{{ formatMessage(messages.noCompletions) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon, TerminalSquareIcon } from '@modrinth/assets'
|
||||
import type { IBufferCell, Terminal } from '@xterm/xterm'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import {
|
||||
applyJLineCandidate,
|
||||
createJLineLineReplacementSequence,
|
||||
extractJLinePrompt,
|
||||
jlineKeySequence,
|
||||
type JLineCandidate,
|
||||
type JLineCell,
|
||||
type JLineCellStyle,
|
||||
type JLineRow,
|
||||
parseJLineCandidateConfirmation,
|
||||
parseJLineCandidates,
|
||||
replaceJLineSelection,
|
||||
} from '../jline'
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
sendInput: (data: Uint8Array) => void | Promise<void>
|
||||
sendCommand: (command: string) => void | Promise<void>
|
||||
resizeConsole: (cols: number, rows: number) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const input = ref<HTMLTextAreaElement | null>(null)
|
||||
const fallbackValue = ref('')
|
||||
const prompt = ref<ReturnType<typeof extractJLinePrompt>>(null)
|
||||
const shellPrompt = ref<ReturnType<typeof extractJLinePrompt>>(null)
|
||||
const completionPrompt = ref<ReturnType<typeof extractJLinePrompt>>(null)
|
||||
const candidates = ref<JLineCandidate[]>([])
|
||||
const candidateSearchQuery = ref('')
|
||||
const focusedCandidateIndex = ref(-1)
|
||||
const enhanced = ref(false)
|
||||
const composing = ref(false)
|
||||
const menuRequested = ref(false)
|
||||
const pointerSelecting = ref(false)
|
||||
let terminal: Terminal | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let pendingWrites: Uint8Array[] = []
|
||||
let inputWriteQueue = Promise.resolve()
|
||||
const encoder = new TextEncoder()
|
||||
const TERMINAL_ROWS = 12
|
||||
const INITIAL_CANDIDATE_TERMINAL_ROWS = 512
|
||||
const MAX_CANDIDATE_TERMINAL_ROWS = 8192
|
||||
let terminalRows = TERMINAL_ROWS
|
||||
let candidateConfirmationPending = false
|
||||
let suppressCompositionEnter = false
|
||||
let compositionEnterTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let compositionSelection: { start: number; end: number } | null = null
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
searchCompletions: {
|
||||
id: 'console.command-completions.search',
|
||||
defaultMessage: 'Search command completions',
|
||||
},
|
||||
noCompletions: {
|
||||
id: 'console.command-completions.no-results',
|
||||
defaultMessage: 'No matching completions',
|
||||
},
|
||||
})
|
||||
|
||||
const filteredCandidates = computed(() => {
|
||||
const query = candidateSearchQuery.value.trim().toLowerCase()
|
||||
if (!query) return candidates.value
|
||||
return candidates.value.filter((candidate) => candidate.text.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
watch(candidates, (list) => {
|
||||
if (list.length === 0) {
|
||||
candidateSearchQuery.value = ''
|
||||
focusedCandidateIndex.value = -1
|
||||
return
|
||||
}
|
||||
focusedCandidateIndex.value = filteredCandidates.value.length ? 0 : -1
|
||||
})
|
||||
|
||||
watch(candidateSearchQuery, () => {
|
||||
focusedCandidateIndex.value = filteredCandidates.value.length > 0 ? 0 : -1
|
||||
})
|
||||
|
||||
const styledInput = computed(() => {
|
||||
if (!prompt.value) return []
|
||||
const cells = prompt.value.rows.flatMap((row) => row.cells).slice(2)
|
||||
const segments: Array<{ text: string; style: Record<string, string> }> = []
|
||||
let remaining = prompt.value.value.length
|
||||
for (const cell of cells) {
|
||||
if (remaining <= 0 || !cell.text) continue
|
||||
const text = cell.text.slice(0, remaining)
|
||||
remaining -= text.length
|
||||
const style = styleToCss(cell.style)
|
||||
const previous = segments.at(-1)
|
||||
if (previous && JSON.stringify(previous.style) === JSON.stringify(style)) {
|
||||
previous.text += text
|
||||
} else {
|
||||
segments.push({ text, style })
|
||||
}
|
||||
}
|
||||
return segments
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('pointerup', finishPointerSelection)
|
||||
window.addEventListener('pointercancel', finishPointerSelection)
|
||||
const { Terminal } = await import('@xterm/xterm')
|
||||
terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: terminalRows,
|
||||
scrollback: 0,
|
||||
convertEol: false,
|
||||
allowProposedApi: true,
|
||||
})
|
||||
for (const bytes of pendingWrites) terminal.write(bytes, updateFromTerminal)
|
||||
pendingWrites = []
|
||||
resizeObserver = new ResizeObserver(resize)
|
||||
if (root.value) resizeObserver.observe(root.value)
|
||||
resize()
|
||||
await sendText('\x0c')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (compositionEnterTimer) window.clearTimeout(compositionEnterTimer)
|
||||
window.removeEventListener('pointerup', finishPointerSelection)
|
||||
window.removeEventListener('pointercancel', finishPointerSelection)
|
||||
resizeObserver?.disconnect()
|
||||
terminal?.dispose()
|
||||
})
|
||||
|
||||
function write(data: Uint8Array) {
|
||||
if (!terminal) {
|
||||
pendingWrites.push(data)
|
||||
return
|
||||
}
|
||||
terminal.write(data, updateFromTerminal)
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!root.value || !terminal) return
|
||||
const fontSize = Number.parseFloat(getComputedStyle(root.value).fontSize) || 16
|
||||
const cellWidth = fontSize * 0.61
|
||||
const cols = Math.max(20, Math.floor((root.value.clientWidth - 52) / cellWidth))
|
||||
if (terminal.cols !== cols || terminal.rows !== terminalRows) {
|
||||
terminal.resize(cols, terminalRows)
|
||||
}
|
||||
void Promise.resolve(props.resizeConsole(cols, terminalRows)).catch(() => {})
|
||||
}
|
||||
|
||||
function updateFromTerminal() {
|
||||
if (!terminal) return
|
||||
const restoreFocus = document.activeElement === input.value
|
||||
const buffer = terminal.buffer.active
|
||||
const cursorRow = buffer.baseY + buffer.cursorY
|
||||
const rows: JLineRow[] = []
|
||||
for (let index = buffer.viewportY; index < buffer.viewportY + terminal.rows; index++) {
|
||||
const line = buffer.getLine(index)
|
||||
if (!line) continue
|
||||
const cells: JLineCell[] = []
|
||||
const reusable = buffer.getNullCell()
|
||||
for (let column = 0; column < terminal.cols; column++) {
|
||||
const cell = line.getCell(column, reusable)
|
||||
if (!cell) continue
|
||||
cells.push({
|
||||
text: cell.getWidth() === 0 ? '' : cell.getChars() || ' ',
|
||||
width: cell.getWidth(),
|
||||
style: cellStyle(cell),
|
||||
})
|
||||
}
|
||||
rows.push({ index, wrapped: line.isWrapped, cells })
|
||||
}
|
||||
const nextPrompt = extractJLinePrompt(rows, cursorRow, buffer.cursorX)
|
||||
if (nextPrompt) shellPrompt.value = nextPrompt
|
||||
prompt.value = menuRequested.value && completionPrompt.value ? completionPrompt.value : nextPrompt
|
||||
if (nextPrompt) enhanced.value = true
|
||||
if (menuRequested.value) {
|
||||
const confirmation = parseJLineCandidateConfirmation(rows)
|
||||
if (confirmation && !candidateConfirmationPending) {
|
||||
candidateConfirmationPending = true
|
||||
void confirmCandidateMenu(confirmation.lineCount)
|
||||
return
|
||||
}
|
||||
const parsedCandidates = parseJLineCandidates(rows, cursorRow)
|
||||
if (parsedCandidates.length > 0) {
|
||||
candidateConfirmationPending = false
|
||||
if (!sameCandidates(candidates.value, parsedCandidates)) candidates.value = parsedCandidates
|
||||
} else if (parsedCandidates.length === 0 && candidates.value.length === 0) {
|
||||
if (nextPrompt) {
|
||||
completionPrompt.value = nextPrompt
|
||||
prompt.value = nextPrompt
|
||||
}
|
||||
}
|
||||
}
|
||||
void nextTick(() => {
|
||||
if (!input.value || !prompt.value || composing.value) return
|
||||
if (pointerSelecting.value || hasTextSelection()) return
|
||||
if (restoreFocus) input.value.focus()
|
||||
const cursor = Math.min(prompt.value.cursor, prompt.value.value.length)
|
||||
input.value.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
function cellStyle(cell: IBufferCell): JLineCellStyle {
|
||||
return {
|
||||
foreground: resolveColor(cell, 'foreground'),
|
||||
background: resolveColor(cell, 'background'),
|
||||
bold: Boolean(cell.isBold()),
|
||||
italic: Boolean(cell.isItalic()),
|
||||
underline: Boolean(cell.isUnderline()),
|
||||
dim: Boolean(cell.isDim()),
|
||||
inverse: Boolean(cell.isInverse()),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveColor(cell: IBufferCell, kind: 'foreground' | 'background') {
|
||||
const rgb = kind === 'foreground' ? cell.isFgRGB() : cell.isBgRGB()
|
||||
const palette = kind === 'foreground' ? cell.isFgPalette() : cell.isBgPalette()
|
||||
const color = kind === 'foreground' ? cell.getFgColor() : cell.getBgColor()
|
||||
if (rgb) return `#${color.toString(16).padStart(6, '0')}`
|
||||
if (!palette) return undefined
|
||||
return ansiPaletteColor(color)
|
||||
}
|
||||
|
||||
function styleToCss(style: JLineCellStyle): Record<string, string> {
|
||||
let foreground = style.foreground ?? 'var(--color-text-default)'
|
||||
let background = style.background ?? 'var(--surface-4)'
|
||||
if (style.inverse) [foreground, background] = [background, foreground]
|
||||
return {
|
||||
...(foreground ? { color: foreground } : {}),
|
||||
...(background ? { backgroundColor: background } : {}),
|
||||
...(style.bold ? { fontWeight: '700' } : {}),
|
||||
...(style.italic ? { fontStyle: 'italic' } : {}),
|
||||
...(style.underline ? { textDecoration: 'underline' } : {}),
|
||||
...(style.dim ? { opacity: '0.65' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function candidateStyleToCss(candidate: JLineCandidate, focused: boolean): Record<string, string> {
|
||||
const style = candidate.style
|
||||
return {
|
||||
...(!focused && style.foreground ? { color: style.foreground } : {}),
|
||||
...(style.bold ? { fontWeight: '700' } : {}),
|
||||
...(style.italic ? { fontStyle: 'italic' } : {}),
|
||||
...(style.underline ? { textDecoration: 'underline' } : {}),
|
||||
...(style.dim ? { opacity: '0.65' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function sameCandidates(current: JLineCandidate[], next: JLineCandidate[]) {
|
||||
return (
|
||||
current.length === next.length &&
|
||||
current.every(
|
||||
(candidate, index) =>
|
||||
candidate.text === next[index]?.text &&
|
||||
candidate.row === next[index]?.row &&
|
||||
candidate.column === next[index]?.column,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function ansiPaletteColor(index: number): string {
|
||||
if (ANSI_COLORS[index]) return ANSI_COLORS[index]
|
||||
if (index >= 16 && index <= 231) {
|
||||
const value = index - 16
|
||||
const red = Math.floor(value / 36)
|
||||
const green = Math.floor((value % 36) / 6)
|
||||
const blue = value % 6
|
||||
const channel = (part: number) => (part === 0 ? 0 : 55 + part * 40)
|
||||
return `rgb(${channel(red)}, ${channel(green)}, ${channel(blue)})`
|
||||
}
|
||||
const gray = 8 + Math.max(0, Math.min(23, index - 232)) * 10
|
||||
return `rgb(${gray}, ${gray}, ${gray})`
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (composing.value || event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === 'Enter' && suppressCompositionEnter) {
|
||||
event.preventDefault()
|
||||
suppressCompositionEnter = false
|
||||
if (compositionEnterTimer) window.clearTimeout(compositionEnterTimer)
|
||||
return
|
||||
}
|
||||
suppressCompositionEnter = false
|
||||
const selection = inputSelection()
|
||||
if (
|
||||
selection &&
|
||||
selection.start !== selection.end &&
|
||||
(event.key === 'Backspace' || event.key === 'Delete')
|
||||
) {
|
||||
event.preventDefault()
|
||||
replaceSelection('', selection.start, selection.end)
|
||||
return
|
||||
}
|
||||
if (candidates.value.length > 0) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveCandidateFocus(event.key === 'ArrowDown' ? 1 : -1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
moveCandidateFocus(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
selectFocusedCandidate()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (event.key === 'Tab' && menuRequested.value && candidates.value.length > 0) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const sequence = keySequence(event.key)
|
||||
if (!sequence) return
|
||||
event.preventDefault()
|
||||
if (menuRequested.value) {
|
||||
if (event.key === 'Tab') {
|
||||
void sendText(sequence)
|
||||
return
|
||||
}
|
||||
continueFromCompletion(event.key === 'Escape' ? '' : sequence)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Tab' && !menuRequested.value) {
|
||||
completionPrompt.value = prompt.value
|
||||
menuRequested.value = true
|
||||
void openCandidateMenu(sequence)
|
||||
return
|
||||
}
|
||||
clearCompletion()
|
||||
void sendText(sequence)
|
||||
}
|
||||
|
||||
function syncPointerCursor() {
|
||||
if (!input.value || !prompt.value) return
|
||||
if (hasTextSelection()) return
|
||||
const target = input.value.selectionStart ?? prompt.value.cursor
|
||||
const distance = target - prompt.value.cursor
|
||||
if (distance === 0) return
|
||||
const key = distance < 0 ? 'ArrowLeft' : 'ArrowRight'
|
||||
const sequence = keySequence(key)
|
||||
if (sequence) void sendText(sequence.repeat(Math.abs(distance)))
|
||||
}
|
||||
|
||||
function keySequence(key: string) {
|
||||
return jlineKeySequence(key, terminal?.modes.applicationCursorKeysMode ?? false)
|
||||
}
|
||||
|
||||
function handleBeforeInput(event: InputEvent) {
|
||||
if (composing.value || event.inputType === 'insertFromPaste') return
|
||||
const selection = inputSelection()
|
||||
if (event.inputType.startsWith('delete')) {
|
||||
if (!selection) return
|
||||
let { start, end } = selection
|
||||
if (start === end && event.inputType.includes('Backward') && start > 0) start--
|
||||
if (
|
||||
start === end &&
|
||||
event.inputType.includes('Forward') &&
|
||||
end < (prompt.value?.value.length ?? 0)
|
||||
) {
|
||||
end++
|
||||
}
|
||||
if (start === end) return
|
||||
event.preventDefault()
|
||||
replaceSelection('', start, end)
|
||||
return
|
||||
}
|
||||
if (!event.inputType.startsWith('insert') || !event.data) return
|
||||
event.preventDefault()
|
||||
if (selection && (selection.start !== selection.end || menuRequested.value)) {
|
||||
replaceSelection(event.data, selection.start, selection.end)
|
||||
return
|
||||
}
|
||||
clearCompletion()
|
||||
void sendText(event.data)
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData('text')
|
||||
if (!text) return
|
||||
event.preventDefault()
|
||||
const selection = inputSelection()
|
||||
if (selection && (selection.start !== selection.end || menuRequested.value)) {
|
||||
replaceSelection(text, selection.start, selection.end)
|
||||
return
|
||||
}
|
||||
clearCompletion()
|
||||
void sendText(text)
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
composing.value = true
|
||||
compositionSelection = inputSelection()
|
||||
suppressCompositionEnter = false
|
||||
if (compositionEnterTimer) window.clearTimeout(compositionEnterTimer)
|
||||
}
|
||||
|
||||
function handleCompositionEnd(event: CompositionEvent) {
|
||||
composing.value = false
|
||||
suppressCompositionEnter = true
|
||||
if (compositionEnterTimer) window.clearTimeout(compositionEnterTimer)
|
||||
compositionEnterTimer = window.setTimeout(() => {
|
||||
suppressCompositionEnter = false
|
||||
compositionEnterTimer = null
|
||||
}, 100)
|
||||
const selection = compositionSelection
|
||||
compositionSelection = null
|
||||
if (event.data && selection && (selection.start !== selection.end || menuRequested.value)) {
|
||||
replaceSelection(event.data, selection.start, selection.end)
|
||||
} else {
|
||||
clearCompletion()
|
||||
if (event.data) void sendText(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
function sendText(text: string) {
|
||||
const data = encoder.encode(text)
|
||||
inputWriteQueue = inputWriteQueue
|
||||
.then(() => props.sendInput(data))
|
||||
.catch(() => {
|
||||
// A stop can race the final keystroke; keep the queue usable for the next write.
|
||||
})
|
||||
return inputWriteQueue
|
||||
}
|
||||
|
||||
function selectCandidate(target: JLineCandidate) {
|
||||
const base = completionPrompt.value ?? prompt.value
|
||||
const current = shellPrompt.value ?? prompt.value
|
||||
if (!base || !current) return
|
||||
const next = applyJLineCandidate(base, target.text)
|
||||
clearCompletion()
|
||||
void sendText(
|
||||
createJLineLineReplacementSequence(
|
||||
current.value,
|
||||
next,
|
||||
terminal?.modes.applicationCursorKeysMode ?? false,
|
||||
),
|
||||
)
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function selectFocusedCandidate() {
|
||||
const candidate = filteredCandidates.value[focusedCandidateIndex.value]
|
||||
if (candidate) void selectCandidate(candidate)
|
||||
}
|
||||
|
||||
function moveCandidateFocus(delta: number) {
|
||||
const count = filteredCandidates.value.length
|
||||
if (count === 0) return
|
||||
focusedCandidateIndex.value = (focusedCandidateIndex.value + delta + count) % count
|
||||
void nextTick(() => {
|
||||
root.value
|
||||
?.querySelector('[data-completion-focused="true"]')
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
|
||||
function closeCandidateSearch() {
|
||||
candidateSearchQuery.value = ''
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function replaceSelection(replacement: string, start: number, end: number) {
|
||||
const displayed = prompt.value
|
||||
const current = shellPrompt.value ?? displayed
|
||||
if (!displayed || !current) return
|
||||
const next = replaceJLineSelection(displayed, start, end, replacement)
|
||||
clearCompletion()
|
||||
void sendText(
|
||||
createJLineLineReplacementSequence(
|
||||
current.value,
|
||||
next,
|
||||
terminal?.modes.applicationCursorKeysMode ?? false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function continueFromCompletion(sequence: string) {
|
||||
const base = completionPrompt.value ?? prompt.value
|
||||
const current = shellPrompt.value ?? prompt.value
|
||||
if (!base || !current) return
|
||||
const next = { value: base.value, cursor: base.cursor }
|
||||
clearCompletion()
|
||||
void sendText(
|
||||
`${createJLineLineReplacementSequence(current.value, next, terminal?.modes.applicationCursorKeysMode ?? false)}${sequence}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function openCandidateMenu(sequence: string) {
|
||||
if (terminal) {
|
||||
terminalRows = INITIAL_CANDIDATE_TERMINAL_ROWS
|
||||
if (terminal.rows !== terminalRows) terminal.resize(terminal.cols, terminalRows)
|
||||
await Promise.resolve(props.resizeConsole(terminal.cols, terminalRows)).catch(() => {})
|
||||
}
|
||||
await sendText(sequence)
|
||||
}
|
||||
|
||||
async function confirmCandidateMenu(lineCount: number) {
|
||||
if (!terminal || lineCount <= 0 || lineCount + 4 > MAX_CANDIDATE_TERMINAL_ROWS) {
|
||||
await sendText('n')
|
||||
clearCompletion()
|
||||
return
|
||||
}
|
||||
terminalRows = Math.max(INITIAL_CANDIDATE_TERMINAL_ROWS, lineCount + 4)
|
||||
if (terminal.rows !== terminalRows) terminal.resize(terminal.cols, terminalRows)
|
||||
await Promise.resolve(props.resizeConsole(terminal.cols, terminalRows)).catch(() => {})
|
||||
await sendText('y')
|
||||
}
|
||||
|
||||
function clearCompletion() {
|
||||
candidateConfirmationPending = false
|
||||
menuRequested.value = false
|
||||
completionPrompt.value = null
|
||||
candidates.value = []
|
||||
candidateSearchQuery.value = ''
|
||||
resetTerminalRows()
|
||||
}
|
||||
|
||||
function resetTerminalRows() {
|
||||
if (!terminal || terminalRows === TERMINAL_ROWS) return
|
||||
terminalRows = TERMINAL_ROWS
|
||||
if (terminal.rows !== terminalRows) terminal.resize(terminal.cols, terminalRows)
|
||||
void Promise.resolve(props.resizeConsole(terminal.cols, terminalRows)).catch(() => {})
|
||||
}
|
||||
|
||||
function inputSelection() {
|
||||
if (!input.value) return null
|
||||
return {
|
||||
start: input.value.selectionStart ?? 0,
|
||||
end: input.value.selectionEnd ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function hasTextSelection() {
|
||||
const selection = inputSelection()
|
||||
return Boolean(selection && selection.start !== selection.end)
|
||||
}
|
||||
|
||||
function finishPointerSelection() {
|
||||
pointerSelecting.value = false
|
||||
}
|
||||
|
||||
function submitFallback() {
|
||||
const command = fallbackValue.value.trim()
|
||||
if (!command || props.disabled) return
|
||||
void Promise.resolve(props.sendCommand(command)).catch(() => {})
|
||||
fallbackValue.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ write })
|
||||
|
||||
const ANSI_COLORS = [
|
||||
'#1d1f23',
|
||||
'#ff496e',
|
||||
'#1bd96a',
|
||||
'#ffa347',
|
||||
'#4a9eff',
|
||||
'#bc3fbc',
|
||||
'#96a2b0',
|
||||
'#b0bac5',
|
||||
'#42444a',
|
||||
'#ff496e',
|
||||
'#1bd96a',
|
||||
'#ffa347',
|
||||
'#4a9eff',
|
||||
'#bc3fbc',
|
||||
'#96a2b0',
|
||||
'#ffffff',
|
||||
]
|
||||
</script>
|
||||
@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<div
|
||||
ref="viewportRef"
|
||||
class="log-viewport font-mono"
|
||||
:class="{ 'log-viewport-wrap': wrap, 'overflow-x-hidden': wrap }"
|
||||
:style="{ fontSize: fontSize + 'px' }"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div v-if="lines.length === 0" class="flex items-center justify-center h-full">
|
||||
<EmptyState
|
||||
v-if="emptyStateType === 'instance'"
|
||||
:heading="formatMessage(consoleMessages.emptyInstanceTitle)"
|
||||
:description="formatMessage(consoleMessages.emptyInstanceDescription)"
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="emptyStateType === 'server'"
|
||||
:heading="formatMessage(consoleMessages.emptyServerTitle)"
|
||||
:description="formatMessage(consoleMessages.emptyServerDescription)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="log-viewport-spacer relative w-full min-w-max"
|
||||
:style="{ height: totalHeight + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 top-0"
|
||||
:style="{ transform: 'translateY(' + topOffset + 'px)' }"
|
||||
>
|
||||
<div
|
||||
v-for="item in windowItems"
|
||||
:key="item.originalIndex"
|
||||
:data-line="item.originalIndex + 1"
|
||||
class="log-line flex items-stretch whitespace-pre"
|
||||
:class="entryClass(item.line)"
|
||||
:style="{ height: estimateHeight(item) + 'px' }"
|
||||
>
|
||||
<span
|
||||
class="flex shrink-0 w-[52px] items-center justify-end leading-none text-right text-secondary bg-surface-3 border-r border-solid border-surface-3 select-none overflow-hidden"
|
||||
>{{ item.originalIndex + 1 }}</span
|
||||
>
|
||||
<span
|
||||
class="log-line-content flex-1 px-2 break-all [overflow-wrap:anywhere]"
|
||||
v-html="renderLine(item)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="scroll-to-bottom-fade">
|
||||
<div v-if="lines.length > 0 && !stickToBottom" class="absolute bottom-4 right-4 z-10">
|
||||
<ButtonStyled circular type="highlight" size="large">
|
||||
<button aria-label="Scroll to bottom" @click="scrollToBottom">
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 日志查看器:交互与布局参考 LogShare-Web-UI (src/views/LogView.vue),
|
||||
// 逐行正则高亮来自 ./composables/log-highlight.ts(移植自 logParser.worker.ts)。
|
||||
// LogShare-Web-UI 为 MIT License, Copyright (c) 2024 LogShare.CN Team,详见 packages/ui/COPYING.md。
|
||||
import { ChevronDownIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import { highlightLine } from '../composables/log-highlight'
|
||||
import { consoleMessages } from '../messages'
|
||||
import type { LogLine } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
interface ViewportLine {
|
||||
line: LogLine
|
||||
originalIndex: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
lines: ViewportLine[]
|
||||
searchQuery?: string
|
||||
wrap?: boolean
|
||||
fontSize?: number
|
||||
emptyStateType?: 'server' | 'instance'
|
||||
}>(),
|
||||
{
|
||||
searchQuery: '',
|
||||
wrap: false,
|
||||
fontSize: 12,
|
||||
emptyStateType: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const viewportRef = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const viewportHeight = ref(0)
|
||||
const stickToBottom = ref(true)
|
||||
|
||||
// 行高:单行 = 字号 × 1.4(与等宽字体匹配),wrap 时按估算折行数放大
|
||||
const lineHeightPx = computed(() => Math.round(props.fontSize * 1.4))
|
||||
// wrap 折行估算:0.6em 为等宽字符平均宽,乘 0.9 留保守余量(行高宁高勿矮,避免内容溢出重叠)
|
||||
const charsPerLine = computed(() => {
|
||||
const vp = viewportRef.value
|
||||
if (!vp) return 120
|
||||
return Math.max(20, Math.floor((vp.clientWidth / (props.fontSize * 0.6)) * 0.9))
|
||||
})
|
||||
|
||||
function estimateHeight(item: ViewportLine): number {
|
||||
if (!props.wrap) return lineHeightPx.value
|
||||
const lines = Math.max(1, Math.ceil(item.line.text.length / charsPerLine.value))
|
||||
return lines * lineHeightPx.value
|
||||
}
|
||||
|
||||
// 高度前缀和缓存:lines/wrap/fontSize 变化时重建(O(n)),滚动时二分查找(O(log n))
|
||||
// 总高度必须是响应式的:普通变量 + 无依赖 computed 会缓存过期值,
|
||||
// 清空控制台后模板不再读取它,重启后 spacer 会以旧高度渲染(底部空白)。
|
||||
let heightPrefix: number[] | null = null
|
||||
const heightTotal = ref(0)
|
||||
|
||||
function rebuildHeights() {
|
||||
const n = props.lines.length
|
||||
if (!props.wrap) {
|
||||
heightPrefix = null
|
||||
heightTotal.value = n * lineHeightPx.value
|
||||
return
|
||||
}
|
||||
const prefix = new Array<number>(n)
|
||||
let acc = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
prefix[i] = acc
|
||||
acc += estimateHeight(props.lines[i]!)
|
||||
}
|
||||
heightPrefix = prefix
|
||||
heightTotal.value = acc
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.lines, props.wrap, props.fontSize] as const,
|
||||
([lines], previous) => {
|
||||
rebuildHeights()
|
||||
// A fresh stream after an empty console (clear, restart, initial
|
||||
// hydration) always resumes bottom-following.
|
||||
if (previous && previous[0].length === 0 && lines.length > 0) {
|
||||
stickToBottom.value = true
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
// Reset the virtual window state along with the DOM scroll position;
|
||||
// browsers may clamp silently without firing a scroll event.
|
||||
scrollTop.value = 0
|
||||
if (viewportRef.value) viewportRef.value.scrollTop = 0
|
||||
}
|
||||
if (stickToBottom.value) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const totalHeight = computed(() => heightTotal.value)
|
||||
|
||||
// 虚拟窗口:可见行 + 上下缓冲
|
||||
const WINDOW_BUFFER = 15
|
||||
|
||||
function computeWindow(): { items: ViewportLine[]; startIndex: number } {
|
||||
const n = props.lines.length
|
||||
if (n === 0) return { items: [], startIndex: 0 }
|
||||
|
||||
let start = 0
|
||||
let end = n - 1
|
||||
|
||||
if (n > WINDOW_BUFFER * 2) {
|
||||
if (props.wrap && heightPrefix) {
|
||||
let lo = 0
|
||||
let hi = n - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1
|
||||
if (heightPrefix[mid]! <= scrollTop.value) lo = mid
|
||||
else hi = mid - 1
|
||||
}
|
||||
start = Math.max(0, lo - WINDOW_BUFFER)
|
||||
} else {
|
||||
const first = Math.floor(scrollTop.value / lineHeightPx.value)
|
||||
start = Math.max(0, first - WINDOW_BUFFER)
|
||||
}
|
||||
end = Math.min(
|
||||
n - 1,
|
||||
start + Math.ceil(viewportHeight.value / lineHeightPx.value) + WINDOW_BUFFER * 2,
|
||||
)
|
||||
}
|
||||
|
||||
return { items: props.lines.slice(start, end + 1), startIndex: start }
|
||||
}
|
||||
|
||||
const windowState = computed(computeWindow)
|
||||
const windowItems = computed(() => windowState.value.items)
|
||||
|
||||
const topOffset = computed(() => {
|
||||
const { startIndex } = windowState.value
|
||||
if (startIndex === 0) return 0
|
||||
if (props.wrap && heightPrefix) return heightPrefix[startIndex]!
|
||||
return startIndex * lineHeightPx.value
|
||||
})
|
||||
|
||||
function entryClass(line: LogLine): string {
|
||||
if (line.level === 'error') return 'entry-error'
|
||||
if (line.level === 'warn') return 'entry-warning'
|
||||
return 'entry-no-error'
|
||||
}
|
||||
|
||||
function renderLine(item: ViewportLine): string {
|
||||
let text = item.line.text
|
||||
if (props.searchQuery) {
|
||||
const terms = props.searchQuery
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length > 0)
|
||||
if (terms.length > 0) {
|
||||
for (const term of [...terms].sort((a, b) => b.length - a.length)) {
|
||||
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
text = text.replace(new RegExp(`(${escaped})`, 'gi'), '<mark>$1</mark>')
|
||||
}
|
||||
}
|
||||
}
|
||||
return highlightLine(text)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const vp = viewportRef.value
|
||||
if (!vp) return
|
||||
scrollTop.value = vp.scrollTop
|
||||
viewportHeight.value = vp.clientHeight
|
||||
stickToBottom.value = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - lineHeightPx.value * 2
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const vp = viewportRef.value
|
||||
if (!vp) return
|
||||
vp.scrollTop = vp.scrollHeight
|
||||
scrollTop.value = vp.scrollTop
|
||||
stickToBottom.value = true
|
||||
}
|
||||
|
||||
function syncViewportSize() {
|
||||
const vp = viewportRef.value
|
||||
if (!vp) return
|
||||
viewportHeight.value = vp.clientHeight
|
||||
// 窗口宽度影响 wrap 折行估算,resize 时重建高度缓存
|
||||
if (props.wrap) rebuildHeights()
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
syncViewportSize()
|
||||
if (stickToBottom.value) nextTick(scrollToBottom)
|
||||
resizeObserver = new ResizeObserver(syncViewportSize)
|
||||
if (viewportRef.value) resizeObserver.observe(viewportRef.value)
|
||||
window.addEventListener('resize', syncViewportSize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
window.removeEventListener('resize', syncViewportSize)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.log-viewport {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
background-color: var(--surface-2);
|
||||
color: var(--color-text-default);
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-fade-enter-active,
|
||||
.scroll-to-bottom-fade-leave-active {
|
||||
transition: opacity 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-fade-enter-from,
|
||||
.scroll-to-bottom-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.log-viewport-wrap .log-viewport-spacer {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-viewport-wrap .log-line {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.log-viewport-wrap .log-line-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-line.entry-error {
|
||||
background-color: color-mix(in srgb, var(--color-red) 12%, transparent);
|
||||
}
|
||||
|
||||
.log-line.entry-warning {
|
||||
background-color: color-mix(in srgb, var(--color-orange) 12%, transparent);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .log-line.entry-error {
|
||||
background-color: color-mix(in srgb, var(--color-red) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .log-line.entry-warning {
|
||||
background-color: color-mix(in srgb, var(--color-orange) 18%, transparent);
|
||||
}
|
||||
|
||||
.log-line mark {
|
||||
padding: 0 0.1em;
|
||||
background-color: color-mix(in srgb, var(--color-blue) 45%, transparent);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== LogShare token 高亮(LogsAnalysis.css 移植,前景色用主题变量) ===== */
|
||||
|
||||
.level {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.level-error,
|
||||
.level-critical,
|
||||
.level-emergency {
|
||||
color: var(--color-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-warning {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.level-fatal {
|
||||
color: var(--color-red);
|
||||
font-weight: 700;
|
||||
background-color: color-mix(in srgb, var(--color-red) 8%, transparent);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .level-fatal {
|
||||
background-color: color-mix(in srgb, var(--color-red) 15%, transparent);
|
||||
}
|
||||
|
||||
.level-debug,
|
||||
.level-notice {
|
||||
color: var(--color-text-secondary);
|
||||
background-color: color-mix(in srgb, var(--color-blue) 5%, transparent);
|
||||
}
|
||||
|
||||
.level-notice {
|
||||
background-color: color-mix(in srgb, var(--color-blue) 10%, transparent);
|
||||
}
|
||||
|
||||
.level-timestamp {
|
||||
color: var(--color-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level-info-prefix {
|
||||
color: var(--color-green);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level-thread {
|
||||
color: var(--color-blue);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.level-error-word {
|
||||
color: var(--color-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-warning-tag {
|
||||
color: var(--color-orange);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-plugin {
|
||||
color: var(--color-green);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.level-filepath {
|
||||
color: var(--color-blue);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.level-dimmed {
|
||||
color: var(--color-text-tertiary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.level-stack-frame {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.level-stack-class {
|
||||
color: var(--color-orange);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level-stack-location {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.level-stack-caused-by {
|
||||
color: var(--color-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-stack-exception {
|
||||
color: var(--color-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-mod-header {
|
||||
color: var(--color-purple);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.level-mod-id {
|
||||
color: var(--color-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level-mod-version {
|
||||
color: #d4a72c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level-mod-name {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.level-mod-status {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.level-mod-status-ok {
|
||||
color: var(--color-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-mod-status-error {
|
||||
color: var(--color-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-mod-status-warn {
|
||||
color: var(--color-orange);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-mod-tree {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.level-mod-dim {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.level-env-key {
|
||||
color: var(--color-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level-section-header {
|
||||
color: var(--color-purple);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.level-arg-flag {
|
||||
color: #d4a72c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level-section-marker {
|
||||
color: var(--color-orange);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Minecraft § 颜色码 */
|
||||
|
||||
.format-black {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.format-darkblue {
|
||||
color: #0000aa;
|
||||
}
|
||||
|
||||
.format-darkgreen {
|
||||
color: #00aa00;
|
||||
}
|
||||
|
||||
.format-darkaqua {
|
||||
color: #00aaaa;
|
||||
}
|
||||
|
||||
.format-darkred {
|
||||
color: #aa0000;
|
||||
}
|
||||
|
||||
.format-darkpurple {
|
||||
color: #aa00aa;
|
||||
}
|
||||
|
||||
.format-gold {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.format-gray {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.format-darkgray {
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.format-blue {
|
||||
color: #5555ff;
|
||||
}
|
||||
|
||||
.format-green {
|
||||
color: #55ff55;
|
||||
}
|
||||
|
||||
.format-aqua {
|
||||
color: #55ffff;
|
||||
}
|
||||
|
||||
.format-red {
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.format-lightpurple {
|
||||
color: #ff55ff;
|
||||
}
|
||||
|
||||
.format-yellow {
|
||||
color: #ffff55;
|
||||
}
|
||||
|
||||
.format-white {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.format-reset {
|
||||
color: var(--color-text-default);
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.format-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.format-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.format-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.format-strike {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,31 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { LogLevel, LogLine } from '../types'
|
||||
|
||||
export type FilterPredicate = (line: LogLine) => boolean
|
||||
|
||||
export type ConditionalLevel = 'debug' | 'trace'
|
||||
|
||||
export function useConsoleFilters() {
|
||||
const activeFilters = ref<Set<LogLevel>>(new Set(['error', 'warn', 'info']))
|
||||
|
||||
function toggleFilter(level: LogLevel) {
|
||||
const next = new Set(activeFilters.value)
|
||||
if (next.has(level)) {
|
||||
next.delete(level)
|
||||
} else {
|
||||
next.add(level)
|
||||
}
|
||||
activeFilters.value = next
|
||||
}
|
||||
|
||||
function buildFilterPredicate(): FilterPredicate | null {
|
||||
if (activeFilters.value.size === 0) return () => false
|
||||
const allowed = activeFilters.value
|
||||
return (line: LogLine) => {
|
||||
return allowed.has(line.level ?? 'info')
|
||||
}
|
||||
}
|
||||
|
||||
return { activeFilters, toggleFilter, buildFilterPredicate }
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export { type ConditionalLevel, type FilterPredicate, useConsoleFilters } from './console-filtering'
|
||||
export type { LogHighlightMode } from './log-highlight'
|
||||
export { clearHighlightCache, detectStrictLevel, highlightLine } from './log-highlight'
|
||||
export { detectLogLevel } from './log-level'
|
||||
@ -0,0 +1,648 @@
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
/**
|
||||
* 日志高亮引擎 — 移植自 LogShare-Web-UI (src/lib/logParser.worker.ts)
|
||||
*
|
||||
* 逐行正则 + Trie 预筛 + LRU 缓存,输出 HTML(span + class),
|
||||
* 样式类与 LogShare 的 LogsAnalysis.css 对应(由 LogViewport 提供样式)。
|
||||
* LogShare-Web-UI 为 MIT License, Copyright (c) 2024 LogShare.CN Team,详见 packages/ui/COPYING.md。
|
||||
* 零外部依赖,可在 Node 中独立运行(用于 benchmark)。
|
||||
*/
|
||||
|
||||
export type LogHighlightMode = 'full' | 'lite' | 'line-only' | 'raw'
|
||||
|
||||
/**
|
||||
* 单行长度保险:超过阈值跳过行内 token 高亮,只做级别检测;
|
||||
* 超过硬上限完全不解析。防止超长行(Base64、巨型堆栈)拖垮渲染链路。
|
||||
*/
|
||||
const LONG_LINE_LIMIT = 4096
|
||||
const LONG_LINE_HARD_LIMIT = 65536
|
||||
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
|
||||
const RE_HTML_ESCAPE = /[&<>"']/g
|
||||
|
||||
const RE_MARK_OPEN = /<mark>/gi
|
||||
const RE_MARK_CLOSE = /<\/mark>/gi
|
||||
|
||||
/**
|
||||
* Minecraft § 颜色码 → 样式类(与 LogShare CSS 的 .format-* 对应)
|
||||
* k(混淆)在 LogShare CSS 中未定义样式,这里同样忽略
|
||||
*/
|
||||
const COLOR_STYLE_MAP: Record<string, string> = {
|
||||
'0': 'format-black',
|
||||
'1': 'format-darkblue',
|
||||
'2': 'format-darkgreen',
|
||||
'3': 'format-darkaqua',
|
||||
'4': 'format-darkred',
|
||||
'5': 'format-darkpurple',
|
||||
'6': 'format-gold',
|
||||
'7': 'format-gray',
|
||||
'8': 'format-darkgray',
|
||||
'9': 'format-blue',
|
||||
a: 'format-green',
|
||||
b: 'format-aqua',
|
||||
c: 'format-red',
|
||||
d: 'format-lightpurple',
|
||||
e: 'format-yellow',
|
||||
f: 'format-white',
|
||||
l: 'format-bold',
|
||||
m: 'format-strike',
|
||||
n: 'format-underline',
|
||||
o: 'format-italic',
|
||||
r: 'format-reset',
|
||||
}
|
||||
|
||||
const RE_COLOR_CODE = /§([0-9a-fk-or])/gi
|
||||
|
||||
const RE_WARN = /(?:\[|: |(?:\/\s))WARN(?:ING)?(?:]|:|\s)/i
|
||||
const RE_ERROR_LEVEL = /(?:\[|: |(?:\/\s))(?:ERR(?:OR)?|FATAL|CRITICAL|EMERGENCY|SEVERE)(?:]|:|\s)/i
|
||||
const RE_DEBUG = /(?:\[|: |(?:\/\s))DEBUG(?:]|:|\s)/i
|
||||
const RE_TRACE = /(?:\[|: |(?:\/\s))TRACE(?:]|:|\s)/i
|
||||
const RE_NOTICE = /(?:\[|: |(?:\/\s))NOTICE(?:]|:|\s)/i
|
||||
|
||||
const RE_EXCEPTION_NAME = /\b[A-Za-z0-9_$]*(?:Exception|Error|Throwable)\b/
|
||||
const RE_STACK_AT = /^\s*at\s+/
|
||||
const RE_CAUSED_BY = /^Caused by:\s*/
|
||||
const RE_STACK_FRAME = /^(\s*)(at\s+)([^(]+)(\(([^)]+)\))?/
|
||||
const RE_EXCEPTION_CLASS =
|
||||
/([A-Za-z0-9_$]+(?:\.[A-Za-z0-9_$]+)*\.)?([A-Za-z0-9_$]*(?:Exception|Error|Throwable))\b/g
|
||||
const RE_MORE_STACK = /^\s*\.\.\.\s+\d+\s+more\s*$/
|
||||
const RE_SUPPRESSED = /^\s*Suppressed:\s+/
|
||||
|
||||
const RE_PYTHON_TRACEBACK = /^Traceback\s*\(most\s+recent\s+call\s+last\)\s*:\s*$/
|
||||
const RE_PYTHON_FILE = /^\s*File\s+"[^"]*",\s*line\s+\d+/i
|
||||
|
||||
const RE_ERROR_PREFIX = /^(?:\s*\[?\s*)?(?:(?:ERROR?\s*[:;]|FATAL\s*[:;]|CRITICAL\s*[:;]))/i
|
||||
const RE_FAIL_KEYWORDS =
|
||||
/\b(?:Failed\s+to|Cannot\s+|Unable\s+to|Could\s+not|Illegal\s+|Invalid\s+|Unsupported\s+|Not\s+found\s*[:;]|Missing\s+)/i
|
||||
|
||||
const RE_THREAD_PREFIX = /(\[[^\]]+\/(?:INFO|WARN(?:ING)?|ERROR?|FATAL|DEBUG|TRACE|NOTICE)\])/g
|
||||
|
||||
const RE_BRACKET_TAG =
|
||||
/(?:^|\s)(\[[A-Za-z0-9_\u00a1-\uffff][A-Za-z0-9_\u00a1-\uffff .\-/]{0,32}\])(?=\s|$)/g
|
||||
|
||||
const RE_FATAL_LEVEL = /\b(?:FATAL|CRITICAL|EMERGENCY)\b/i
|
||||
|
||||
const RE_MOD_LIST_HEADER = /(?:Loading\s+\d*\s*mods?\s*:?|--\s*Mod\s+List\s*--|Mod\s+List:?)\s*$/i
|
||||
|
||||
const RE_MOD_ENTRY = /(\s+[-*]\s+)([A-Za-z_][\w.-]*)((?:\s+|@))(\S[\S ]*)$/
|
||||
|
||||
const RE_MOD_TABLE_HEADER = /^\|\s*(?:Id|Name|Version|Status)\s*\|/i
|
||||
|
||||
const RE_MOD_TABLE_ROW =
|
||||
/^(\|\s+)([\w.-]+)(\s+\|\s+)(\S[\S ]*?)(\s+\|\s+)([^|]+?)(\s+\|\s+)(\S[\S ]*?)(\s+\|)$/
|
||||
|
||||
const RE_FABRIC_LOADER_HEADER = /Loading\s+Minecraft\s+[\d.]+\s+with\s+Fabric\s+Loader\s+\S+/i
|
||||
|
||||
const RE_MOD_TREE_ENTRY = /^(\s*)([|\\]--\s+)([A-Za-z_][\w.-]*)((?:\s+|@))(\S[\S ]*)$/
|
||||
|
||||
const RE_NEOCRSH_MOD_ROW =
|
||||
/^(.+?\.jar)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+?)\s*\|\s*(Manifest:\s*\S+)/i
|
||||
|
||||
const RE_NEOMOD_LIST_HEADER = /^\s*Name\s+Version\s+\(Mod\s+Id\)\s*$/i
|
||||
|
||||
const RE_NEOMOD_LIST_ITEM = /^\s*(?!\[)(.+?)\s+\(([a-z_][\w.-]*)\)$/
|
||||
|
||||
const RE_ARGS_LINE = /(?:ModLauncher\s+running:\s*args|JVM\s*Args?:|^\s*--\w)/i
|
||||
|
||||
const RE_ARG_FLAG = /(--[A-Za-z_][\w.-]*)/g
|
||||
|
||||
class LRUCache<V> {
|
||||
private capacity: number
|
||||
private cache: Map<string, V>
|
||||
|
||||
constructor(capacity: number) {
|
||||
this.capacity = capacity
|
||||
this.cache = new Map()
|
||||
}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
if (!this.cache.has(key)) return undefined
|
||||
const value = this.cache.get(key)!
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key)
|
||||
} else if (this.cache.size >= this.capacity) {
|
||||
const firstKey = this.cache.keys().next().value
|
||||
if (firstKey !== undefined) this.cache.delete(firstKey)
|
||||
}
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const formatCache = new LRUCache<string>(2000)
|
||||
|
||||
class TrieNode {
|
||||
children: Map<string, TrieNode> = new Map()
|
||||
id: number = -1
|
||||
}
|
||||
|
||||
class Trie {
|
||||
root: TrieNode = new TrieNode()
|
||||
|
||||
insert(word: string, id: number): void {
|
||||
let node = this.root
|
||||
for (const ch of word) {
|
||||
let child = node.children.get(ch)
|
||||
if (!child) {
|
||||
child = new TrieNode()
|
||||
node.children.set(ch, child)
|
||||
}
|
||||
node = child
|
||||
}
|
||||
node.id = id
|
||||
}
|
||||
}
|
||||
|
||||
function isWordChar(ch: string): boolean {
|
||||
return (
|
||||
(ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch === '_'
|
||||
)
|
||||
}
|
||||
|
||||
function trieCollect(text: string, trie: Trie): Array<{ start: number; end: number; id: number }> {
|
||||
const results: Array<{ start: number; end: number; id: number }> = []
|
||||
const len = text.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
let node = trie.root
|
||||
let j = i
|
||||
|
||||
while (j < len) {
|
||||
const ch = text[j]!
|
||||
const child = node.children.get(ch)
|
||||
if (!child) break
|
||||
node = child
|
||||
j++
|
||||
if (node.id !== -1) {
|
||||
const prev = i > 0 ? text[i - 1]! : ' '
|
||||
const next = j < len ? text[j]! : ' '
|
||||
if (!isWordChar(prev) && !isWordChar(next)) {
|
||||
results.push({ start: i, end: j, id: node.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 Trie 一次扫描完成关键词高亮替换(matches 需已按 start 排序,忽略重叠)
|
||||
*/
|
||||
function trieHighlight(
|
||||
text: string,
|
||||
matches: Array<{ start: number; end: number; id: number }>,
|
||||
classMap: Record<number, string>,
|
||||
): string {
|
||||
if (matches.length === 0) return text
|
||||
|
||||
const parts: string[] = []
|
||||
let lastEnd = 0
|
||||
|
||||
for (const m of matches) {
|
||||
if (m.start < lastEnd) continue
|
||||
parts.push(text.slice(lastEnd, m.start))
|
||||
parts.push('<span class="' + classMap[m.id] + '">')
|
||||
parts.push(text.slice(m.start, m.end))
|
||||
parts.push('</span>')
|
||||
lastEnd = m.end
|
||||
}
|
||||
parts.push(text.slice(lastEnd))
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
const ENV_KEYWORD_MAP: Record<number, string> = {
|
||||
0: 'level-mod-status-ok',
|
||||
1: 'level-mod-status-ok',
|
||||
2: 'level-mod-status-ok',
|
||||
3: 'level-mod-status-error',
|
||||
4: 'level-mod-status-error',
|
||||
5: 'level-mod-status-error',
|
||||
6: 'level-env-key',
|
||||
7: 'level-env-key',
|
||||
}
|
||||
|
||||
function buildEnvTrie(): Trie {
|
||||
const t = new Trie()
|
||||
t.insert('success', 0)
|
||||
t.insert('Success', 1)
|
||||
t.insert('SUCCESS', 2)
|
||||
t.insert('failed', 3)
|
||||
t.insert('Failed', 4)
|
||||
t.insert('FAILED', 5)
|
||||
t.insert('DLOPEN', 6)
|
||||
t.insert('dlopen', 7)
|
||||
return t
|
||||
}
|
||||
|
||||
const envKeywordTrie = buildEnvTrie()
|
||||
|
||||
type EngineLevel = 'error' | 'warning' | 'debug' | 'info' | 'fatal' | 'trace'
|
||||
|
||||
function buildLevelTrie(): Trie {
|
||||
const t = new Trie()
|
||||
let id = 0
|
||||
const add = (w: string) => t.insert(w, id++)
|
||||
add('FATAL')
|
||||
add('CRITICAL')
|
||||
add('EMERGENCY')
|
||||
add('SEVERE')
|
||||
add('ERROR')
|
||||
add('ERR')
|
||||
add('WARN')
|
||||
add('WARNING')
|
||||
add('DEBUG')
|
||||
add('TRACE')
|
||||
add('NOTICE')
|
||||
add('Exception')
|
||||
add('Throwable')
|
||||
add('Caused')
|
||||
add('Suppressed')
|
||||
add('Traceback')
|
||||
add('Failed')
|
||||
add('Cannot')
|
||||
add('Unable')
|
||||
add('Could')
|
||||
add('Illegal')
|
||||
add('Invalid')
|
||||
add('Unsupported')
|
||||
add('Missing')
|
||||
add('Stacktrace:')
|
||||
add('Details:')
|
||||
return t
|
||||
}
|
||||
|
||||
const levelTrie = buildLevelTrie()
|
||||
|
||||
function getLevel(line: string): EngineLevel {
|
||||
if (RE_PYTHON_TRACEBACK.test(line)) return 'error'
|
||||
if (RE_PYTHON_FILE.test(line)) return 'error'
|
||||
if (RE_STACK_AT.test(line)) return 'error'
|
||||
if (RE_CAUSED_BY.test(line)) return 'error'
|
||||
if (RE_MORE_STACK.test(line)) return 'error'
|
||||
if (RE_SUPPRESSED.test(line)) return 'error'
|
||||
if (RE_EXCEPTION_NAME.test(line)) return 'error'
|
||||
if (RE_ERROR_PREFIX.test(line)) return 'error'
|
||||
|
||||
if (/^\s*(?:Stacktrace|Details):/.test(line)) return 'error'
|
||||
if (/^-- Affected level --$/.test(line)) return 'error'
|
||||
|
||||
const levelHits = trieCollect(line, levelTrie)
|
||||
if (levelHits.length === 0) return 'info'
|
||||
|
||||
if (RE_FATAL_LEVEL.test(line)) return 'fatal'
|
||||
if (RE_ERROR_LEVEL.test(line)) return 'error'
|
||||
if (RE_WARN.test(line)) return 'warning'
|
||||
if (RE_TRACE.test(line)) return 'trace'
|
||||
if (RE_DEBUG.test(line)) return 'debug'
|
||||
if (RE_NOTICE.test(line)) return 'info'
|
||||
|
||||
if (
|
||||
/^\s*(?:Failed\s+to|Cannot\s+|Unable\s+to|Could\s+not|Illegal\s+|Invalid\s+|Unsupported\s+|Not\s+found\s*[:;]|Missing\s+)/i.test(
|
||||
line,
|
||||
)
|
||||
) {
|
||||
return 'error'
|
||||
}
|
||||
|
||||
if (RE_FAIL_KEYWORDS.test(line)) return 'warning'
|
||||
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function mapLevel(level: EngineLevel): LogLevel {
|
||||
if (level === 'warning') return 'warn'
|
||||
if (level === 'fatal') return 'error'
|
||||
return level
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并常用高亮模式为单次扫描:时间戳 | ISO时间戳 | 文件路径 | IPv4 | 行内错误关键词
|
||||
*/
|
||||
const RE_COMMON_HIGHLIGHTS =
|
||||
/(\[\d{1,2}:\d{2}:\d{2}(?:[.,]\d{3,6})?\])|(\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z)?\b)|(\/(?:[A-Za-z0-9_.@-]+(?:\/[A-Za-z0-9_.@-]+)+))|(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d{2,5})?\b)|(\b(?:Error|Exception|Warning|Fatal|Critical)\b)/g
|
||||
|
||||
function applyCommonHighlights(out: string): string {
|
||||
return out.replace(RE_COMMON_HIGHLIGHTS, (match, ts, isots, fp, ip, errword) => {
|
||||
if (ts) return '<span class="level-timestamp">' + ts + '</span>'
|
||||
if (isots) return '<span class="level-timestamp">' + isots + '</span>'
|
||||
if (fp) return '<span class="level-filepath">' + fp + '</span>'
|
||||
if (ip) return '<span class="level-dimmed">' + ip + '</span>'
|
||||
if (errword) return '<span class="level-error-word">' + errword + '</span>'
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
function applyStackHighlights(out: string, text: string): string {
|
||||
if (RE_STACK_AT.test(text)) {
|
||||
out = out.replace(RE_STACK_FRAME, (_, indent, atKw, className, _paren, location) => {
|
||||
const locHtml = location ? '<span class="level-stack-location">(' + location + ')</span>' : ''
|
||||
return (
|
||||
indent +
|
||||
'<span class="level-stack-frame">' +
|
||||
atKw +
|
||||
'</span><span class="level-stack-class">' +
|
||||
className +
|
||||
'</span>' +
|
||||
locHtml
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
out = out.replace(
|
||||
/^(Caused by:\s*)(.+)$/m,
|
||||
'<span class="level-stack-caused-by">$1</span><span class="level-stack-exception">$2</span>',
|
||||
)
|
||||
out = out.replace(
|
||||
/^(Suppressed:\s*)(.+)$/m,
|
||||
'<span class="level-stack-caused-by">$1</span><span class="level-stack-exception">$2</span>',
|
||||
)
|
||||
|
||||
if (!RE_STACK_AT.test(text) && RE_EXCEPTION_NAME.test(text)) {
|
||||
out = out.replace(RE_EXCEPTION_CLASS, '<span class="level-stack-exception">$1$2</span>')
|
||||
}
|
||||
|
||||
if (RE_PYTHON_FILE.test(text)) {
|
||||
out = out.replace(
|
||||
/^(\s*File\s+)("[^"]*")(\s*,\s*line\s+)(\d+)/i,
|
||||
'<span class="level-stack-frame">$1</span><span class="level-stack-location">$2$3</span><span class="level-stack-class">$4</span>',
|
||||
)
|
||||
}
|
||||
if (RE_PYTHON_TRACEBACK.test(text)) {
|
||||
out = out.replace(
|
||||
/^(Traceback\s*\()(most recent call last)(\)\s*:\s*)$/,
|
||||
'<span class="level-stack-frame">$1</span><span class="level-stack-location">$2</span><span class="level-stack-frame">$3</span>',
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function applyModHighlights(out: string, text: string): string {
|
||||
if (RE_MOD_LIST_HEADER.test(text)) {
|
||||
if (/--\s*Mod\s+List\s*--/i.test(text)) {
|
||||
out = out.replace(
|
||||
/^(\s*)(--\s*Mod\s+List\s*--)(\s*)$/,
|
||||
'$1<span class="level-mod-header">$2</span>$3',
|
||||
)
|
||||
} else if (/Mod\s+List:?/i.test(text)) {
|
||||
out = out.replace(
|
||||
/^(.*?)(Mod\s+List:?)(.*)$/i,
|
||||
'$1<span class="level-mod-header">$2</span>$3',
|
||||
)
|
||||
} else {
|
||||
out = out.replace(
|
||||
/^(.*?)(Loading\s+\d*\s*mods?\s*:?)(.*)$/i,
|
||||
'$1<span class="level-mod-header">$2</span>$3',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (RE_MOD_ENTRY.test(text)) {
|
||||
out = out.replace(
|
||||
RE_MOD_ENTRY,
|
||||
'$1<span class="level-mod-id">$2</span>$3<span class="level-mod-version">$4</span>',
|
||||
)
|
||||
}
|
||||
|
||||
if (RE_MOD_TABLE_HEADER.test(text)) {
|
||||
out = out.replace(/^(\|)([\s\w|]+)(\|)$/, (_, open, body, close) => {
|
||||
const cells = body.split('|').map((c: string) => c.trim())
|
||||
const wrapped = cells
|
||||
.map((c: string) => '<span class="level-mod-header">' + c + '</span>')
|
||||
.join(' | ')
|
||||
return open + ' ' + wrapped + ' ' + close
|
||||
})
|
||||
}
|
||||
|
||||
if (RE_MOD_TABLE_ROW.test(text)) {
|
||||
out = out.replace(
|
||||
RE_MOD_TABLE_ROW,
|
||||
(_match, open, id, sep1, version, sep2, name, sep3, status, close) => {
|
||||
const statusClean = status.trim().toLowerCase()
|
||||
let statusClass = 'level-mod-status'
|
||||
if (statusClean === 'ok' || statusClean === 'done') {
|
||||
statusClass += ' level-mod-status-ok'
|
||||
} else if (
|
||||
statusClean === 'error' ||
|
||||
statusClean === 'missing' ||
|
||||
statusClean === 'outdated'
|
||||
) {
|
||||
statusClass += ' level-mod-status-error'
|
||||
} else if (statusClean === 'warning') {
|
||||
statusClass += ' level-mod-status-warn'
|
||||
}
|
||||
return (
|
||||
open +
|
||||
'<span class="level-mod-id">' +
|
||||
id +
|
||||
'</span>' +
|
||||
sep1 +
|
||||
'<span class="level-mod-version">' +
|
||||
version +
|
||||
'</span>' +
|
||||
sep2 +
|
||||
'<span class="level-mod-name">' +
|
||||
name.trim() +
|
||||
'</span>' +
|
||||
sep3 +
|
||||
'<span class="' +
|
||||
statusClass +
|
||||
'">' +
|
||||
status.trim() +
|
||||
'</span>' +
|
||||
close
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (RE_FABRIC_LOADER_HEADER.test(text)) {
|
||||
out = out.replace(RE_FABRIC_LOADER_HEADER, '<span class="level-mod-header">$&</span>')
|
||||
}
|
||||
|
||||
if (RE_MOD_TREE_ENTRY.test(text)) {
|
||||
out = out.replace(
|
||||
RE_MOD_TREE_ENTRY,
|
||||
'$1<span class="level-mod-tree">$2</span><span class="level-mod-id">$3</span>$4<span class="level-mod-version">$5</span>',
|
||||
)
|
||||
}
|
||||
|
||||
if (RE_NEOCRSH_MOD_ROW.test(text)) {
|
||||
out = out.replace(
|
||||
RE_NEOCRSH_MOD_ROW,
|
||||
'<span class="level-mod-dim">$1</span> | <span class="level-mod-name">$2</span> | <span class="level-mod-id">$3</span> | <span class="level-mod-version">$4</span> | <span class="level-mod-status">$5</span>',
|
||||
)
|
||||
}
|
||||
|
||||
if (RE_NEOMOD_LIST_HEADER.test(text)) {
|
||||
out = out.replace(RE_NEOMOD_LIST_HEADER, '<span class="level-mod-header">$&</span>')
|
||||
}
|
||||
|
||||
if (RE_NEOMOD_LIST_ITEM.test(text)) {
|
||||
out = out.replace(
|
||||
RE_NEOMOD_LIST_ITEM,
|
||||
'<span class="level-mod-name">$1</span> (<span class="level-mod-id">$2</span>)',
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行格式化:text → HTML(span + class)
|
||||
*/
|
||||
function formatLine(text: string, mode: LogHighlightMode): string {
|
||||
const cached = formatCache.get(mode + text)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
let out = escapeHtml(text)
|
||||
|
||||
// 恢复搜索高亮(搜索层注入的 <mark> 已转义)
|
||||
out = out.replace(RE_MARK_OPEN, '<mark>').replace(RE_MARK_CLOSE, '</mark>')
|
||||
|
||||
// Minecraft § 颜色码
|
||||
out = out.replace(RE_COLOR_CODE, (_match, code) => {
|
||||
const cls = COLOR_STYLE_MAP[code.toLowerCase() as keyof typeof COLOR_STYLE_MAP]
|
||||
return cls ? '<span class="' + cls + '">' : _match
|
||||
})
|
||||
|
||||
if (mode === 'full') {
|
||||
out = applyStackHighlights(out, text)
|
||||
out = applyModHighlights(out, text)
|
||||
}
|
||||
|
||||
// [Thread/LEVEL] 前缀
|
||||
out = out.replace(RE_THREAD_PREFIX, (match) => {
|
||||
const sepIdx = match.lastIndexOf('/')
|
||||
if (sepIdx === -1) return match
|
||||
const thread = match.slice(0, sepIdx) + '/'
|
||||
const level = match.slice(sepIdx + 1, -1)
|
||||
const levelLower = level.toLowerCase()
|
||||
const levelClass =
|
||||
levelLower === 'error' || levelLower === 'fatal'
|
||||
? 'level-error-word'
|
||||
: levelLower === 'warn' || levelLower === 'warning'
|
||||
? 'level-warning-tag'
|
||||
: 'level-info-prefix'
|
||||
return (
|
||||
'<span class="level-thread">' +
|
||||
escapeHtml(thread) +
|
||||
'</span><span class="' +
|
||||
levelClass +
|
||||
'">' +
|
||||
escapeHtml(level) +
|
||||
'</span>]'
|
||||
)
|
||||
})
|
||||
|
||||
// [插件名] 标记(保留前导空格,LogShare 原版会吞掉)
|
||||
out = out.replace(RE_BRACKET_TAG, (match) => {
|
||||
const leading = /^\s*/.exec(match)?.[0] ?? ''
|
||||
const tag = match.trim()
|
||||
if (tag.length > 40) return match
|
||||
return leading + '<span class="level-plugin">' + escapeHtml(tag) + '</span>'
|
||||
})
|
||||
|
||||
// 一次扫描完成:时间戳 / 文件路径 / IPv4 / 错误关键词
|
||||
out = applyCommonHighlights(out)
|
||||
|
||||
// 分隔线 / 环境变量 / DLOPEN / 启动参数
|
||||
out = out.replace(
|
||||
/^(={5,})\s*([^=\n]*)\s*(={5,})$/m,
|
||||
'<span class="level-section-header">$&</span>',
|
||||
)
|
||||
|
||||
out = out.replace(
|
||||
/^(\s*)((?:Added\s+)?[Ee]nv\s*:\s*)/m,
|
||||
'$1<span class="level-env-key">$2</span>',
|
||||
)
|
||||
|
||||
const envMatches = trieCollect(out, envKeywordTrie)
|
||||
if (envMatches.length > 0) {
|
||||
envMatches.sort((a, b) => a.start - b.start)
|
||||
out = trieHighlight(out, envMatches, ENV_KEYWORD_MAP)
|
||||
}
|
||||
|
||||
out = out.replace(
|
||||
/(Environment:\s*)(Environment\[[^\]]*\])/g,
|
||||
'$1<span class="level-env-key">$2</span>',
|
||||
)
|
||||
|
||||
if (RE_ARGS_LINE.test(text)) {
|
||||
out = out.replace(RE_ARG_FLAG, '<span class="level-arg-flag">$1</span>')
|
||||
out = out.replace(/(^|\s)(-D[A-Za-z_][\w.]*)/gm, '$1<span class="level-arg-flag">$2</span>')
|
||||
}
|
||||
|
||||
out = out.replace(/\b(Stacktrace:|Details:)/g, '<span class="level-section-marker">$1</span>')
|
||||
out = out.replace(/^-- Affected level --$/m, '<span class="level-section-header">$&</span>')
|
||||
|
||||
formatCache.set(mode + text, out)
|
||||
return out
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(RE_HTML_ESCAPE, (ch) => HTML_ESCAPE_MAP[ch] || ch)
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内级别整行着色(line-only 模式 / 降级兜底)
|
||||
*/
|
||||
export function colorizeByLevel(level: LogLevel | null, text: string): string {
|
||||
const levelClass =
|
||||
level === 'error'
|
||||
? 'level-error'
|
||||
: level === 'warn'
|
||||
? 'level-warning'
|
||||
: level === 'debug' || level === 'trace'
|
||||
? 'level-debug'
|
||||
: 'level-info'
|
||||
return '<span class="level ' + levelClass + '">' + escapeHtml(text) + '</span>'
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行高亮入口:text → HTML
|
||||
*
|
||||
* 内置行长度保险:超长行跳过行内 token(只做级别检测),超硬上限完全不解析。
|
||||
*/
|
||||
export function highlightLine(text: string, mode: LogHighlightMode = 'full'): string {
|
||||
if (mode === 'raw') return escapeHtml(text)
|
||||
if (text.length > LONG_LINE_HARD_LIMIT) return escapeHtml(text)
|
||||
if (mode === 'line-only' || text.length > LONG_LINE_LIMIT) {
|
||||
return colorizeByLevel(mapLevel(getLevel(text)), text)
|
||||
}
|
||||
return formatLine(text, mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 级别检测(LogShare 语义,供 line-only 模式与统计使用)
|
||||
*/
|
||||
export function detectStrictLevel(text: string): LogLevel | null {
|
||||
if (text.length > LONG_LINE_HARD_LIMIT) return null
|
||||
return mapLevel(getLevel(text))
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空高亮缓存(一般不需要,测试用)
|
||||
*/
|
||||
export function clearHighlightCache(): void {
|
||||
formatCache.clear()
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
const ERROR_TRIGGERS = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', '\tat']
|
||||
|
||||
export function detectLogLevel(lineText: string): LogLevel | null {
|
||||
if (lineText.includes('/INFO') || lineText.includes('[System] [CHAT]')) return 'info'
|
||||
if (lineText.includes('/WARN')) return 'warn'
|
||||
if (lineText.includes('/DEBUG')) return 'debug'
|
||||
if (lineText.includes('/TRACE')) return 'trace'
|
||||
for (const trigger of ERROR_TRIGGERS) {
|
||||
if (lineText.includes(trigger)) return 'error'
|
||||
}
|
||||
return null
|
||||
}
|
||||
5
packages/ui/src/layouts/shared/console/index.ts
Normal file
5
packages/ui/src/layouts/shared/console/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { default as JLineCommandInput } from './components/JLineCommandInput.vue'
|
||||
export * from './jline'
|
||||
export { default as ConsolePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export * from './types'
|
||||
118
packages/ui/src/layouts/shared/console/jline.test.ts
Normal file
118
packages/ui/src/layouts/shared/console/jline.test.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
applyJLineCandidate,
|
||||
createJLineCandidateInsertion,
|
||||
createJLineLineReplacementSequence,
|
||||
extractJLinePrompt,
|
||||
jlineKeySequence,
|
||||
JLINE_KEY_SEQUENCES,
|
||||
type JLineRow,
|
||||
parseJLineCandidateConfirmation,
|
||||
parseJLineCandidates,
|
||||
replaceJLineSelection,
|
||||
} from './jline.ts'
|
||||
|
||||
const row = (index: number, text: string, wrapped = false, inverse = ''): JLineRow => ({
|
||||
index,
|
||||
wrapped,
|
||||
cells: [...text].map((character) => ({
|
||||
text: character,
|
||||
width: 1,
|
||||
style: { inverse: inverse.includes(character) },
|
||||
})),
|
||||
})
|
||||
|
||||
test('extracts wrapped prompt input and cursor position', () => {
|
||||
const rows = [row(2, '> say a very '), row(3, 'long command', true)]
|
||||
assert.deepEqual(extractJLinePrompt(rows, 3, 4), {
|
||||
value: 'say a very long command',
|
||||
cursor: 15,
|
||||
rows,
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects a line that is not a JLine prompt', () => {
|
||||
assert.equal(extractJLinePrompt([row(1, 'server output')], 1, 3), null)
|
||||
})
|
||||
|
||||
test('preserves spaces entered at the end of the command', () => {
|
||||
const rows = [row(1, '> say ')]
|
||||
assert.equal(extractJLinePrompt(rows, 1, 6)?.value, 'say ')
|
||||
})
|
||||
|
||||
test('builds a direct edit that replaces the candidate token without executing it', () => {
|
||||
assert.deepEqual(
|
||||
createJLineCandidateInsertion({ value: 'gamerule advance_t', cursor: 18 }, 'advance_weather'),
|
||||
{ deleteBefore: 9, deleteAfter: 0, text: 'advance_weather ' },
|
||||
)
|
||||
assert.deepEqual(createJLineCandidateInsertion({ value: 'give stne 1', cursor: 7 }, 'stone'), {
|
||||
deleteBefore: 2,
|
||||
deleteAfter: 2,
|
||||
text: 'stone',
|
||||
})
|
||||
assert.deepEqual(applyJLineCandidate({ value: 'gamerule keep_inventory ', cursor: 24 }, 'true'), {
|
||||
value: 'gamerule keep_inventory true ',
|
||||
cursor: 29,
|
||||
})
|
||||
})
|
||||
|
||||
test('replaces selections and creates a deterministic JLine redraw sequence', () => {
|
||||
const edit = replaceJLineSelection({ value: 'gamerule keep_inventory true' }, 0, 28, '')
|
||||
assert.deepEqual(edit, { value: '', cursor: 0 })
|
||||
assert.equal(
|
||||
createJLineLineReplacementSequence('give stne 1', { value: 'give stone 1', cursor: 10 }),
|
||||
`\x01\x0bgive stone 1${JLINE_KEY_SEQUENCES.ArrowLeft.repeat(2)}`,
|
||||
)
|
||||
assert.ok(
|
||||
createJLineLineReplacementSequence('g', { value: 'gamemode ', cursor: 8 }, true).endsWith(
|
||||
'\x1bOD',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('splits candidate columns and preserves terminal coordinates', () => {
|
||||
const candidates = parseJLineCandidates(
|
||||
[row(4, '> give @p '), row(5, 'stone stick', false, 't')],
|
||||
4,
|
||||
)
|
||||
assert.deepEqual(
|
||||
candidates.map(({ text, row, column, selected }) => ({ text, row, column, selected })),
|
||||
[
|
||||
{ text: 'stone', row: 5, column: 0, selected: true },
|
||||
{ text: 'stick', row: 5, column: 7, selected: true },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects truncated candidate menus and defines terminal key bytes', () => {
|
||||
assert.deepEqual(parseJLineCandidates([row(1, '> g'), row(2, 'one --More--')], 1), [])
|
||||
assert.equal(JLINE_KEY_SEQUENCES.ArrowLeft, '\x1b[D')
|
||||
assert.equal(jlineKeySequence('ArrowLeft', true), '\x1bOD')
|
||||
assert.equal(JLINE_KEY_SEQUENCES.Backspace, '\x7f')
|
||||
})
|
||||
|
||||
test('detects Forge candidate confirmation prompts and their expanded row count', () => {
|
||||
assert.deepEqual(
|
||||
parseJLineCandidateConfirmation([
|
||||
row(1, 'Forge: do you wish to see all 51 '),
|
||||
row(2, 'possibilities (13 lines)?', true),
|
||||
]),
|
||||
{ lineCount: 13 },
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseJLineCandidateConfirmation([
|
||||
row(1, '[Server thread/INFO] Forge: display all 2048 possibilities (512 lines)?'),
|
||||
]),
|
||||
{ lineCount: 512 },
|
||||
)
|
||||
assert.equal(
|
||||
parseJLineCandidateConfirmation([
|
||||
row(1, 'Forge: display all 10 possibilities (2 lines)?'),
|
||||
row(2, '> gamerule'),
|
||||
]),
|
||||
null,
|
||||
)
|
||||
assert.equal(parseJLineCandidateConfirmation([row(1, '> gamerule')]), null)
|
||||
})
|
||||
229
packages/ui/src/layouts/shared/console/jline.ts
Normal file
229
packages/ui/src/layouts/shared/console/jline.ts
Normal file
@ -0,0 +1,229 @@
|
||||
export interface JLineCellStyle {
|
||||
foreground?: string
|
||||
background?: string
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
dim?: boolean
|
||||
inverse?: boolean
|
||||
}
|
||||
|
||||
export interface JLineCell {
|
||||
text: string
|
||||
width: number
|
||||
style: JLineCellStyle
|
||||
}
|
||||
|
||||
export interface JLineRow {
|
||||
index: number
|
||||
wrapped: boolean
|
||||
cells: JLineCell[]
|
||||
}
|
||||
|
||||
export interface JLinePrompt {
|
||||
value: string
|
||||
cursor: number
|
||||
rows: JLineRow[]
|
||||
}
|
||||
|
||||
export interface JLineCandidate {
|
||||
text: string
|
||||
row: number
|
||||
column: number
|
||||
selected: boolean
|
||||
style: JLineCellStyle
|
||||
}
|
||||
|
||||
export interface JLineCandidateConfirmation {
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
export interface JLineCandidateInsertion {
|
||||
deleteBefore: number
|
||||
deleteAfter: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface JLineInputEdit {
|
||||
value: string
|
||||
cursor: number
|
||||
}
|
||||
|
||||
export const JLINE_KEY_SEQUENCES: Record<string, string> = {
|
||||
Tab: '\t',
|
||||
ArrowUp: '\x1b[A',
|
||||
ArrowDown: '\x1b[B',
|
||||
ArrowRight: '\x1b[C',
|
||||
ArrowLeft: '\x1b[D',
|
||||
Home: '\x1b[H',
|
||||
End: '\x1b[F',
|
||||
Delete: '\x1b[3~',
|
||||
Backspace: '\x7f',
|
||||
Enter: '\r',
|
||||
Escape: '\x1b',
|
||||
}
|
||||
|
||||
export function jlineKeySequence(key: string, applicationCursorKeys = false): string | undefined {
|
||||
if (applicationCursorKeys) {
|
||||
const applicationSequences: Record<string, string> = {
|
||||
ArrowUp: '\x1bOA',
|
||||
ArrowDown: '\x1bOB',
|
||||
ArrowRight: '\x1bOC',
|
||||
ArrowLeft: '\x1bOD',
|
||||
Home: '\x1bOH',
|
||||
End: '\x1bOF',
|
||||
}
|
||||
if (applicationSequences[key]) return applicationSequences[key]
|
||||
}
|
||||
return JLINE_KEY_SEQUENCES[key]
|
||||
}
|
||||
|
||||
export function extractJLinePrompt(
|
||||
rows: JLineRow[],
|
||||
cursorRow: number,
|
||||
cursorColumn: number,
|
||||
): JLinePrompt | null {
|
||||
const cursorIndex = rows.findIndex((row) => row.index === cursorRow)
|
||||
if (cursorIndex < 0) return null
|
||||
let start = cursorIndex
|
||||
while (start > 0 && rows[start]?.wrapped) start--
|
||||
const logicalRows = rows.slice(start, cursorIndex + 1)
|
||||
const logicalText = logicalRows.map(rowText).join('')
|
||||
if (!logicalText.startsWith('> ')) return null
|
||||
const charactersBeforeCursor =
|
||||
logicalRows
|
||||
.slice(0, -1)
|
||||
.reduce(
|
||||
(total, row) => total + row.cells.reduce((sum, cell) => sum + cell.text.length, 0),
|
||||
0,
|
||||
) +
|
||||
(logicalRows.at(-1)?.cells ?? [])
|
||||
.slice(0, cursorColumn)
|
||||
.reduce((sum, cell) => sum + cell.text.length, 0)
|
||||
const cursor = Math.max(0, charactersBeforeCursor - 2)
|
||||
const commandText = logicalText.slice(2)
|
||||
const valueEnd = Math.max(cursor, commandText.trimEnd().length)
|
||||
return {
|
||||
value: commandText.slice(0, valueEnd),
|
||||
cursor,
|
||||
rows: logicalRows,
|
||||
}
|
||||
}
|
||||
|
||||
export function createJLineCandidateInsertion(
|
||||
prompt: Pick<JLinePrompt, 'value' | 'cursor'>,
|
||||
candidateText: string,
|
||||
): JLineCandidateInsertion {
|
||||
const cursor = Math.max(0, Math.min(prompt.cursor, prompt.value.length))
|
||||
let start = cursor
|
||||
while (start > 0 && !/\s/.test(prompt.value[start - 1]!)) start--
|
||||
let end = cursor
|
||||
while (end < prompt.value.length && !/\s/.test(prompt.value[end]!)) end++
|
||||
const needsTrailingSpace = end === prompt.value.length && !candidateText.endsWith(' ')
|
||||
return {
|
||||
deleteBefore: cursor - start,
|
||||
deleteAfter: end - cursor,
|
||||
text: `${candidateText}${needsTrailingSpace ? ' ' : ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyJLineCandidate(
|
||||
prompt: Pick<JLinePrompt, 'value' | 'cursor'>,
|
||||
candidateText: string,
|
||||
): JLineInputEdit {
|
||||
const insertion = createJLineCandidateInsertion(prompt, candidateText)
|
||||
const start = prompt.cursor - insertion.deleteBefore
|
||||
const end = prompt.cursor + insertion.deleteAfter
|
||||
return {
|
||||
value: `${prompt.value.slice(0, start)}${insertion.text}${prompt.value.slice(end)}`,
|
||||
cursor: start + insertion.text.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceJLineSelection(
|
||||
prompt: Pick<JLinePrompt, 'value'>,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
replacement: string,
|
||||
): JLineInputEdit {
|
||||
const start = Math.max(0, Math.min(selectionStart, selectionEnd, prompt.value.length))
|
||||
const end = Math.max(start, Math.min(Math.max(selectionStart, selectionEnd), prompt.value.length))
|
||||
return {
|
||||
value: `${prompt.value.slice(0, start)}${replacement}${prompt.value.slice(end)}`,
|
||||
cursor: start + replacement.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function createJLineLineReplacementSequence(
|
||||
currentValue: string,
|
||||
next: JLineInputEdit,
|
||||
applicationCursorKeys = false,
|
||||
): string {
|
||||
const cursor = Math.max(0, Math.min(next.cursor, next.value.length))
|
||||
return (
|
||||
(currentValue ? '\x01\x0b' : '') +
|
||||
next.value +
|
||||
jlineKeySequence('ArrowLeft', applicationCursorKeys)!.repeat(next.value.length - cursor)
|
||||
)
|
||||
}
|
||||
|
||||
export function parseJLineCandidates(rows: JLineRow[], cursorRow: number): JLineCandidate[] {
|
||||
const candidates: JLineCandidate[] = []
|
||||
for (const row of rows) {
|
||||
if (row.index <= cursorRow) continue
|
||||
const text = rowText(row)
|
||||
if (!text.trim()) continue
|
||||
if (/--more--|\.\.\.$/i.test(text.trim())) return []
|
||||
|
||||
let start = 0
|
||||
while (start < row.cells.length) {
|
||||
while (start < row.cells.length && !row.cells[start]?.text.trim()) start++
|
||||
if (start >= row.cells.length) break
|
||||
let end = start
|
||||
let blankRun = 0
|
||||
while (end < row.cells.length) {
|
||||
if (row.cells[end]?.text.trim()) {
|
||||
blankRun = 0
|
||||
} else {
|
||||
blankRun++
|
||||
if (blankRun >= 2) break
|
||||
}
|
||||
end++
|
||||
}
|
||||
const contentEnd = Math.max(start, end - blankRun + 1)
|
||||
const candidateText = row.cells
|
||||
.slice(start, contentEnd)
|
||||
.map((cell) => cell.text)
|
||||
.join('')
|
||||
.trim()
|
||||
if (candidateText) {
|
||||
const first = row.cells[start]!
|
||||
candidates.push({
|
||||
text: candidateText,
|
||||
row: row.index,
|
||||
column: start,
|
||||
selected: row.cells.slice(start, contentEnd).some((cell) => cell.style.inverse),
|
||||
style: first.style,
|
||||
})
|
||||
}
|
||||
start = end + 1
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function parseJLineCandidateConfirmation(
|
||||
rows: JLineRow[],
|
||||
): JLineCandidateConfirmation | null {
|
||||
const text = rows.map(rowText).join(' ').replace(/\s+/g, ' ').trim()
|
||||
const match = text.match(
|
||||
/(?:do you wish to see all|display all)\s+\d+\s+possibilit(?:y|ies)\b.*?\((\d+)\s+lines?\)\?\s*$/i,
|
||||
)
|
||||
if (!match) return null
|
||||
const lineCount = Number.parseInt(match[1] ?? '', 10)
|
||||
return { lineCount: Number.isFinite(lineCount) ? lineCount : 0 }
|
||||
}
|
||||
|
||||
function rowText(row: JLineRow): string {
|
||||
return row.cells.map((cell) => cell.text).join('')
|
||||
}
|
||||
913
packages/ui/src/layouts/shared/console/layout.vue
Normal file
913
packages/ui/src/layouts/shared/console/layout.vue
Normal file
@ -0,0 +1,913 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
:class="
|
||||
isFullscreen ? `fixed inset-0 z-[15] bg-surface-1 p-6 py-8 ${isApp ? 'pt-12' : ''}` : ''
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
(ctx.localCrashAnalysis?.value?.findings.length ||
|
||||
ctx.localCrashAnalysis?.value?.mod_changes.length) &&
|
||||
!isFullscreen
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<CollapsibleAdmonition type="critical" :header="localCrashHeader" :items="localCrashItems" />
|
||||
<div class="flex justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="exportingCrashContext" @click="handleExportCrashContext">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(consoleMessages.exportCrashContext) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleAdmonition
|
||||
v-if="ctx.crashAnalysis?.value && !isFullscreen"
|
||||
type="critical"
|
||||
:header="crashHeader"
|
||||
:items="crashItems"
|
||||
dismissible
|
||||
@dismiss="ctx.onDismissCrash?.()"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(consoleMessages.searchLogs)"
|
||||
wrapper-class="flex-1"
|
||||
input-class="!h-10"
|
||||
clearable
|
||||
/>
|
||||
<div v-if="ctx.logSources?.value && ctx.activeLogSourceIndex" class="w-[220px]">
|
||||
<Combobox
|
||||
:model-value="ctx.activeLogSourceIndex.value"
|
||||
:options="logSourceOptions"
|
||||
@update:model-value="(v) => (ctx.activeLogSourceIndex!.value = v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ConsoleFilterPills v-model="activeFilters" @toggle="handleFilterToggle" />
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<ButtonStyled type="transparent" :highlighted="wrapLines">
|
||||
<button
|
||||
:aria-pressed="wrapLines"
|
||||
:title="formatMessage(consoleMessages.toggleWrap)"
|
||||
@click="wrapLines = !wrapLines"
|
||||
>
|
||||
<WrapTextIcon />
|
||||
{{ formatMessage(consoleMessages.wrapLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="w-28">
|
||||
<Combobox
|
||||
:model-value="logFontSize"
|
||||
:options="fontSizeOptions"
|
||||
@update:model-value="(v) => (logFontSize = v)"
|
||||
/>
|
||||
</div>
|
||||
<ConsoleActionButtons
|
||||
:show-clear="isLiveSource"
|
||||
:has-logs="hasLogs"
|
||||
:share-disabled="resolvedShareDisabled"
|
||||
:sharing="isSharing"
|
||||
:fullscreen="isFullscreen"
|
||||
:clear-disabled="resolvedClearDisabled"
|
||||
:clear-disabled-tooltip="resolvedClearDisabledTooltip"
|
||||
:show-delete="showDelete"
|
||||
:delete-disabled="resolvedDeleteDisabled"
|
||||
:delete-disabled-tooltip="resolvedDeleteDisabledTooltip"
|
||||
@clear="handleClear"
|
||||
@share="handleShare"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden rounded-[20px]">
|
||||
<LogViewport
|
||||
ref="viewportRef"
|
||||
class="h-full"
|
||||
:lines="filteredLines"
|
||||
:search-query="searchQuery"
|
||||
:wrap="wrapLines"
|
||||
:font-size="logFontSize"
|
||||
:empty-state-type="ctx.emptyStateType"
|
||||
/>
|
||||
<Transition name="terminal-loading-fade">
|
||||
<div
|
||||
v-if="resolvedLoading"
|
||||
class="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-surface-3/80 px-8"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
v-if="showCommandInput && customCommandInput"
|
||||
name="command-input"
|
||||
:disabled="commandDisabled"
|
||||
:placeholder="commandPlaceholder"
|
||||
:submit-command="submitProvidedCommand"
|
||||
/>
|
||||
<StyledInput
|
||||
v-else-if="showCommandInput"
|
||||
v-model="commandInput"
|
||||
v-tooltip="commandDisabled ? commandDisabledTooltip : undefined"
|
||||
:icon="TerminalSquareIcon"
|
||||
:placeholder="commandPlaceholder"
|
||||
:disabled="commandDisabled"
|
||||
wrapper-class="w-full"
|
||||
input-class="!h-9"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
@keydown.enter="submitCommand"
|
||||
/>
|
||||
</div>
|
||||
<ShareModal
|
||||
ref="shareModal"
|
||||
:header="formatMessage(consoleMessages.shareLogs)"
|
||||
link
|
||||
:social-buttons="false"
|
||||
/>
|
||||
<NewModal
|
||||
ref="deleteModal"
|
||||
:header="formatMessage(consoleMessages.deleteLogFile)"
|
||||
:fade="'danger'"
|
||||
max-width="500px"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="critical" :header="formatMessage(consoleMessages.deleteIrreversible)">
|
||||
{{ formatMessage(consoleMessages.deleteConfirmation) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="deleteModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isDeleting" @click="confirmDelete">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
SearchIcon,
|
||||
TerminalSquareIcon,
|
||||
TrashIcon,
|
||||
WrapTextIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, isRef, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import type { CollapsibleAdmonitionItem } from '#ui/components/base/CollapsibleAdmonition.vue'
|
||||
import CollapsibleAdmonition from '#ui/components/base/CollapsibleAdmonition.vue'
|
||||
import type { ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
import Combobox from '#ui/components/base/Combobox.vue'
|
||||
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import ShareModal from '#ui/components/modal/ShareModal.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
import { injectModalBehavior } from '#ui/providers/modal-behavior'
|
||||
import { injectPageContext } from '#ui/providers/page-context'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { shareLogs } from '#ui/utils/log-share'
|
||||
|
||||
import ConsoleActionButtons from './components/ConsoleActionButtons.vue'
|
||||
import ConsoleFilterPills from './components/ConsoleFilterPills.vue'
|
||||
import LogViewport from './components/LogViewport.vue'
|
||||
import { useConsoleFilters } from './composables'
|
||||
import { consoleMessages, localFindingMessages } from './messages'
|
||||
import { injectConsoleManager } from './providers'
|
||||
import type { LogLevel, LogLine } from './types'
|
||||
|
||||
const ctx = injectConsoleManager()
|
||||
defineProps<{
|
||||
customCommandInput?: boolean
|
||||
}>()
|
||||
const client = injectModrinthClient()
|
||||
const modalBehavior = injectModalBehavior()
|
||||
const pageContext = injectPageContext(null)
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const localFindingCopy = {
|
||||
jvm_arguments: {
|
||||
title: localFindingMessages.jvmArgumentsTitle,
|
||||
action: localFindingMessages.jvmArgumentsAction,
|
||||
},
|
||||
out_of_memory: {
|
||||
title: localFindingMessages.outOfMemoryTitle,
|
||||
action: localFindingMessages.outOfMemoryAction,
|
||||
},
|
||||
opengl_unsupported: {
|
||||
title: localFindingMessages.openglUnsupportedTitle,
|
||||
action: localFindingMessages.openglUnsupportedAction,
|
||||
},
|
||||
pixel_format: {
|
||||
title: localFindingMessages.pixelFormatTitle,
|
||||
action: localFindingMessages.pixelFormatAction,
|
||||
},
|
||||
openj9: {
|
||||
title: localFindingMessages.openj9Title,
|
||||
action: localFindingMessages.openj9Action,
|
||||
},
|
||||
java_too_new: {
|
||||
title: localFindingMessages.javaTooNewTitle,
|
||||
action: localFindingMessages.javaTooNewAction,
|
||||
},
|
||||
java_incompatible: {
|
||||
title: localFindingMessages.javaIncompatibleTitle,
|
||||
action: localFindingMessages.javaIncompatibleAction,
|
||||
},
|
||||
jdk_runtime: {
|
||||
title: localFindingMessages.jdkRuntimeTitle,
|
||||
action: localFindingMessages.jdkRuntimeAction,
|
||||
},
|
||||
java_32bit: {
|
||||
title: localFindingMessages.java32BitTitle,
|
||||
action: localFindingMessages.java32BitAction,
|
||||
},
|
||||
java_11_required: {
|
||||
title: localFindingMessages.java11RequiredTitle,
|
||||
action: localFindingMessages.java11RequiredAction,
|
||||
},
|
||||
forge_incomplete: {
|
||||
title: localFindingMessages.forgeIncompleteTitle,
|
||||
action: localFindingMessages.forgeIncompleteAction,
|
||||
},
|
||||
duplicate_mod: {
|
||||
title: localFindingMessages.duplicateModTitle,
|
||||
action: localFindingMessages.duplicateModAction,
|
||||
},
|
||||
incompatible_mods: {
|
||||
title: localFindingMessages.incompatibleModsTitle,
|
||||
action: localFindingMessages.incompatibleModsAction,
|
||||
},
|
||||
missing_dependency: {
|
||||
title: localFindingMessages.missingDependencyTitle,
|
||||
action: localFindingMessages.missingDependencyAction,
|
||||
},
|
||||
disk_space: {
|
||||
title: localFindingMessages.diskSpaceTitle,
|
||||
action: localFindingMessages.diskSpaceAction,
|
||||
},
|
||||
file_in_use: {
|
||||
title: localFindingMessages.fileInUseTitle,
|
||||
action: localFindingMessages.fileInUseAction,
|
||||
},
|
||||
connector_incompatible_fabric_mods: {
|
||||
title: localFindingMessages.connectorIncompatibleFabricModsTitle,
|
||||
action: localFindingMessages.connectorIncompatibleFabricModsAction,
|
||||
},
|
||||
missing_embeddium: {
|
||||
title: localFindingMessages.missingEmbeddiumTitle,
|
||||
action: localFindingMessages.missingEmbeddiumAction,
|
||||
},
|
||||
missing_indium: {
|
||||
title: localFindingMessages.missingIndiumTitle,
|
||||
action: localFindingMessages.missingIndiumAction,
|
||||
},
|
||||
mod_id_limit: {
|
||||
title: localFindingMessages.modIdLimitTitle,
|
||||
action: localFindingMessages.modIdLimitAction,
|
||||
},
|
||||
forge_error: {
|
||||
title: localFindingMessages.forgeErrorTitle,
|
||||
action: localFindingMessages.forgeErrorAction,
|
||||
},
|
||||
mod_loader_error: {
|
||||
title: localFindingMessages.modLoaderErrorTitle,
|
||||
action: localFindingMessages.modLoaderErrorAction,
|
||||
},
|
||||
mod_loader_failure: {
|
||||
title: localFindingMessages.modLoaderFailureTitle,
|
||||
action: localFindingMessages.modLoaderFailureAction,
|
||||
},
|
||||
stack_analysis: {
|
||||
title: localFindingMessages.stackAnalysisTitle,
|
||||
action: localFindingMessages.stackAnalysisAction,
|
||||
},
|
||||
short_output: {
|
||||
title: localFindingMessages.shortOutputTitle,
|
||||
action: localFindingMessages.shortOutputAction,
|
||||
},
|
||||
extracted_mod: {
|
||||
title: localFindingMessages.extractedModTitle,
|
||||
action: localFindingMessages.extractedModAction,
|
||||
},
|
||||
mixin_bootstrap: {
|
||||
title: localFindingMessages.mixinBootstrapTitle,
|
||||
action: localFindingMessages.mixinBootstrapAction,
|
||||
},
|
||||
mixin_failure: {
|
||||
title: localFindingMessages.mixinFailureTitle,
|
||||
action: localFindingMessages.mixinFailureAction,
|
||||
},
|
||||
fabric_solution: {
|
||||
title: localFindingMessages.fabricSolutionTitle,
|
||||
action: localFindingMessages.fabricSolutionAction,
|
||||
},
|
||||
mod_config: {
|
||||
title: localFindingMessages.modConfigTitle,
|
||||
action: localFindingMessages.modConfigAction,
|
||||
},
|
||||
optifine_incompatible: {
|
||||
title: localFindingMessages.optifineIncompatibleTitle,
|
||||
action: localFindingMessages.optifineIncompatibleAction,
|
||||
},
|
||||
resource_pack: {
|
||||
title: localFindingMessages.resourcePackTitle,
|
||||
action: localFindingMessages.resourcePackAction,
|
||||
},
|
||||
large_resource_pack: {
|
||||
title: localFindingMessages.largeResourcePackTitle,
|
||||
action: localFindingMessages.largeResourcePackAction,
|
||||
},
|
||||
shaders_optifine: {
|
||||
title: localFindingMessages.shadersOptifineTitle,
|
||||
action: localFindingMessages.shadersOptifineAction,
|
||||
},
|
||||
multiple_forge_versions: {
|
||||
title: localFindingMessages.multipleForgeVersionsTitle,
|
||||
action: localFindingMessages.multipleForgeVersionsAction,
|
||||
},
|
||||
forge_java_incompatible: {
|
||||
title: localFindingMessages.forgeJavaIncompatibleTitle,
|
||||
action: localFindingMessages.forgeJavaIncompatibleAction,
|
||||
},
|
||||
content_verification: {
|
||||
title: localFindingMessages.contentVerificationTitle,
|
||||
action: localFindingMessages.contentVerificationAction,
|
||||
},
|
||||
optifine_world: {
|
||||
title: localFindingMessages.optifineWorldTitle,
|
||||
action: localFindingMessages.optifineWorldAction,
|
||||
},
|
||||
nightconfig_bug: {
|
||||
title: localFindingMessages.nightconfigBugTitle,
|
||||
action: localFindingMessages.nightconfigBugAction,
|
||||
},
|
||||
mod_filename: {
|
||||
title: localFindingMessages.modFilenameTitle,
|
||||
action: localFindingMessages.modFilenameAction,
|
||||
},
|
||||
definite_mod: {
|
||||
title: localFindingMessages.definiteModTitle,
|
||||
action: localFindingMessages.definiteModAction,
|
||||
},
|
||||
definite_mod_fabric: {
|
||||
title: localFindingMessages.definiteModFabricTitle,
|
||||
action: localFindingMessages.definiteModFabricAction,
|
||||
},
|
||||
intel_driver: {
|
||||
title: localFindingMessages.intelDriverTitle,
|
||||
action: localFindingMessages.intelDriverAction,
|
||||
},
|
||||
amd_driver: {
|
||||
title: localFindingMessages.amdDriverTitle,
|
||||
action: localFindingMessages.amdDriverAction,
|
||||
},
|
||||
nvidia_driver: {
|
||||
title: localFindingMessages.nvidiaDriverTitle,
|
||||
action: localFindingMessages.nvidiaDriverAction,
|
||||
},
|
||||
manual_debug_crash: {
|
||||
title: localFindingMessages.manualDebugCrashTitle,
|
||||
action: localFindingMessages.manualDebugCrashAction,
|
||||
},
|
||||
suspected_mod: {
|
||||
title: localFindingMessages.suspectedModTitle,
|
||||
action: localFindingMessages.suspectedModAction,
|
||||
},
|
||||
mod_initialization: {
|
||||
title: localFindingMessages.modInitializationTitle,
|
||||
action: localFindingMessages.modInitializationAction,
|
||||
},
|
||||
specific_block: {
|
||||
title: localFindingMessages.specificBlockTitle,
|
||||
action: localFindingMessages.specificBlockAction,
|
||||
},
|
||||
specific_entity: {
|
||||
title: localFindingMessages.specificEntityTitle,
|
||||
action: localFindingMessages.specificEntityAction,
|
||||
},
|
||||
hs_err_al_lib_alc_cleanup: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
hs_err_glfw_driver: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
hs_err_intel_driver: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
hs_err_java_too_high: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
hs_err_jvm: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
hs_err_openal: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
hs_err_macos_shader: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
hs_err_apple_jdk: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
hs_err_gpu_driver: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
create_addons: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
ctov_missing_lithostitched: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
curseforge_corrupted: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
epic_fight_addons: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
feature_order_cycle: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
ferrite_core_neighbor_table: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
geckolib_oculus_compat: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
groovy_mod_loader_ipv6: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
kubejs_datapack: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
language_provider_mismatch: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
legacy_too_many_ids: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
module_resolution: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
modernfix_watchdog: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
neoforge_1_20_1: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
resource_location: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
rubidium_deprecated: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
server_config_corrupted: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
version_1_21: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
used_by_another_process: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
windows_closed_process: {
|
||||
title: consoleMessages.knownSignatureTitle,
|
||||
action: consoleMessages.knownSignatureAction,
|
||||
},
|
||||
} as const
|
||||
|
||||
const localCrashHeader = computed(() => {
|
||||
const analysis = ctx.localCrashAnalysis?.value
|
||||
const findings = analysis?.findings.length ?? 0
|
||||
const sources = analysis?.sources.length ?? 0
|
||||
return formatMessage(consoleMessages.localCrashHeader, { findings, sources })
|
||||
})
|
||||
|
||||
const localCrashItems = computed<CollapsibleAdmonitionItem[]>(() => {
|
||||
const analysis = ctx.localCrashAnalysis?.value
|
||||
if (!analysis) return []
|
||||
const items = analysis.findings.map((finding) => {
|
||||
const copy = localFindingCopy[finding.id as keyof typeof localFindingCopy]
|
||||
const title = copy
|
||||
? formatMessage(copy.title)
|
||||
: formatMessage(consoleMessages.fallbackFindingTitle, { finding: finding.id })
|
||||
const action = copy
|
||||
? formatMessage(copy.action)
|
||||
: formatMessage(consoleMessages.fallbackFindingAction)
|
||||
const evidence = finding.evidence.map((item) => `${item.filename}:${item.line} - ${item.text}`)
|
||||
const mods = analysis.mods.map((mod) => {
|
||||
const identity = mod.name || mod.id || mod.file_name
|
||||
const modId = mod.id && mod.id !== identity ? ` (${mod.id})` : ''
|
||||
return formatMessage(consoleMessages.matchedMod, {
|
||||
identity,
|
||||
modId,
|
||||
fileName: mod.file_name,
|
||||
})
|
||||
})
|
||||
return {
|
||||
title,
|
||||
descriptions: [action, ...mods, ...evidence],
|
||||
}
|
||||
})
|
||||
if (analysis.mod_changes.length > 0) {
|
||||
const counts = analysis.mod_change_counts
|
||||
const changeKindMessages = {
|
||||
added: consoleMessages.modChangeAdded,
|
||||
removed: consoleMessages.modChangeRemoved,
|
||||
modified: consoleMessages.modChangeModified,
|
||||
} as const
|
||||
items.push({
|
||||
title: formatMessage(consoleMessages.modChangesTitle),
|
||||
descriptions: [
|
||||
formatMessage(consoleMessages.modChangesSummary, counts),
|
||||
...analysis.mod_changes.map((change) =>
|
||||
formatMessage(consoleMessages.modChange, {
|
||||
kind: formatMessage(changeKindMessages[change.kind]),
|
||||
filename: change.filename,
|
||||
}),
|
||||
),
|
||||
],
|
||||
})
|
||||
}
|
||||
if (analysis.windows_events.length > 0) {
|
||||
items.push({
|
||||
title: formatMessage(consoleMessages.windowsEventsTitle),
|
||||
descriptions: analysis.windows_events.map(
|
||||
(event) => `Event ${event.event_id} · ${event.provider}: ${event.message}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const crashHeader = computed(() => {
|
||||
const analysis = ctx.crashAnalysis?.value
|
||||
const findings = analysis?.findings.length ?? 0
|
||||
return formatMessage(consoleMessages.crashHeader, { findings })
|
||||
})
|
||||
|
||||
const crashItems = computed<CollapsibleAdmonitionItem[]>(() => {
|
||||
const analysis = ctx.crashAnalysis?.value
|
||||
if (!analysis) return []
|
||||
return analysis.findings.map((finding) => {
|
||||
const copy = localFindingCopy[finding.id as keyof typeof localFindingCopy]
|
||||
const title = copy
|
||||
? formatMessage(copy.title)
|
||||
: formatMessage(consoleMessages.fallbackFindingTitle, { finding: finding.id })
|
||||
const action = copy
|
||||
? formatMessage(copy.action)
|
||||
: formatMessage(consoleMessages.fallbackFindingAction)
|
||||
const evidence = finding.evidence.map((item) => `${item.filename}:${item.line} - ${item.text}`)
|
||||
const mods = analysis.mods.map((mod) => {
|
||||
const identity = mod.name || mod.id || mod.file_name
|
||||
const modId = mod.id && mod.id !== identity ? ` (${mod.id})` : ''
|
||||
return formatMessage(consoleMessages.matchedMod, {
|
||||
identity,
|
||||
modId,
|
||||
fileName: mod.file_name,
|
||||
})
|
||||
})
|
||||
return {
|
||||
title,
|
||||
descriptions: [action, ...mods, ...evidence],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const viewportRef = ref<InstanceType<typeof LogViewport> | null>(null)
|
||||
const shareModal = ref<InstanceType<typeof ShareModal> | null>(null)
|
||||
const deleteModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const isDeleting = ref(false)
|
||||
const exportingCrashContext = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const wrapLines = ref(false)
|
||||
const logFontSize = ref(12)
|
||||
|
||||
const FONT_SIZES = [8, 10, 12, 14, 16, 18, 20, 24] as const
|
||||
const fontSizeOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
FONT_SIZES.map((size) => ({ value: size, label: `${size}px` })),
|
||||
)
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
const fullscreenBodyClass = 'modrinth-console-fullscreen-active'
|
||||
const fullscreenIntercomPadding = 20
|
||||
const fullscreenIntercomPaddingRequestId = Symbol('console-fullscreen')
|
||||
const isApp =
|
||||
typeof window !== 'undefined' && !!(window as Record<string, unknown>).__TAURI_INTERNALS__
|
||||
const isSharing = ref(false)
|
||||
const { activeFilters, toggleFilter, buildFilterPredicate } = useConsoleFilters()
|
||||
const hasLogs = computed(() => ctx.logLines.value.length > 0)
|
||||
const isLiveSource = computed(() => {
|
||||
const sources = ctx.logSources?.value
|
||||
const index = ctx.activeLogSourceIndex?.value
|
||||
if (!sources || index === undefined) return true
|
||||
return sources[index]?.live ?? true
|
||||
})
|
||||
const logSourceOptions = computed(() =>
|
||||
(ctx.logSources?.value ?? []).map((s, i) => ({ value: i, label: s.name })),
|
||||
)
|
||||
|
||||
async function handleExportCrashContext() {
|
||||
if (!ctx.onExportCrashContext || exportingCrashContext.value) return
|
||||
exportingCrashContext.value = true
|
||||
try {
|
||||
await ctx.onExportCrashContext()
|
||||
} finally {
|
||||
exportingCrashContext.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildCombinedPredicate(): ((line: LogLine) => boolean) | null {
|
||||
const levelPred = buildFilterPredicate()
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!levelPred && !query) return null
|
||||
return (line: LogLine) => {
|
||||
if (levelPred && !levelPred(line)) return false
|
||||
if (query && !line.text.toLowerCase().includes(query)) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLines = computed(() => {
|
||||
const predicate = buildCombinedPredicate()
|
||||
const src = ctx.logLines.value
|
||||
if (!predicate) {
|
||||
return src.map((line, i) => ({ line, originalIndex: i }))
|
||||
}
|
||||
const out: Array<{ line: LogLine; originalIndex: number }> = []
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
if (predicate(src[i]!)) out.push({ line: src[i]!, originalIndex: i })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (isFullscreen.value) {
|
||||
document.body.style.overflow = ''
|
||||
document.body.classList.remove(fullscreenBodyClass)
|
||||
pageContext?.intercomBubble?.requestHorizontalPadding?.(
|
||||
fullscreenIntercomPaddingRequestId,
|
||||
null,
|
||||
)
|
||||
modalBehavior?.onHide?.()
|
||||
}
|
||||
})
|
||||
|
||||
// needs historical log start/end flags on ws to be properly useful
|
||||
const resolvedLoading = computed(() => {
|
||||
const v = ctx.loading
|
||||
if (!v) return false
|
||||
return v.value
|
||||
})
|
||||
|
||||
const resolvedShareDisabled = computed(() => {
|
||||
const v = ctx.shareDisabled
|
||||
if (!v) return false
|
||||
return isRef(v) ? v.value : v
|
||||
})
|
||||
|
||||
const commandInput = ref('')
|
||||
|
||||
const showCommandInput = computed(() => {
|
||||
if (!ctx.sendCommand) return false
|
||||
return unwrapMaybeRef(ctx.showCommandInput) ?? false
|
||||
})
|
||||
|
||||
const commandDisabled = computed(() => unwrapMaybeRef(ctx.disableCommandInput) ?? false)
|
||||
|
||||
const commandDisabledTooltip = computed(() => ctx.disableCommandInputTooltip?.value)
|
||||
|
||||
const commandPlaceholder = computed(() => {
|
||||
if (!commandDisabled.value) return formatMessage(consoleMessages.commandPlaceholder)
|
||||
return formatMessage(
|
||||
ctx.emptyStateType === 'server'
|
||||
? consoleMessages.serverNotRunning
|
||||
: consoleMessages.commandInputDisabled,
|
||||
)
|
||||
})
|
||||
|
||||
function submitCommand() {
|
||||
const command = commandInput.value.trim()
|
||||
if (!command || commandDisabled.value || !ctx.sendCommand) return
|
||||
ctx.sendCommand(command)
|
||||
commandInput.value = ''
|
||||
// The user just interacted with the console: pin the view to the bottom
|
||||
// so the command echo and its response are visible immediately.
|
||||
viewportRef.value?.scrollToBottom()
|
||||
}
|
||||
|
||||
function submitProvidedCommand(command: string) {
|
||||
if (!command.trim() || commandDisabled.value || !ctx.sendCommand) return
|
||||
ctx.sendCommand(command.trim())
|
||||
viewportRef.value?.scrollToBottom()
|
||||
}
|
||||
|
||||
// Re-pins the viewport to the bottom (and re-enables bottom-following).
|
||||
// Exposed so hosts can react to external events such as a server starting.
|
||||
function scrollToBottom() {
|
||||
viewportRef.value?.scrollToBottom()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
})
|
||||
|
||||
const showDelete = computed(() => !isLiveSource.value && ctx.onDelete != null)
|
||||
|
||||
const resolvedDeleteDisabled = computed(() => {
|
||||
const v = ctx.deleteDisabled
|
||||
if (!v) return false
|
||||
return isRef(v) ? v.value : v
|
||||
})
|
||||
|
||||
function unwrapMaybeRef<T>(value: T | { value: T } | undefined): T | undefined {
|
||||
if (value === undefined) return undefined
|
||||
return isRef(value) ? value.value : value
|
||||
}
|
||||
|
||||
const resolvedDeleteDisabledTooltip = computed(() =>
|
||||
resolvedDeleteDisabled.value ? unwrapMaybeRef(ctx.deleteDisabledTooltip) : undefined,
|
||||
)
|
||||
|
||||
const resolvedClearDisabled = computed(() => {
|
||||
const v = ctx.clearDisabled
|
||||
if (!v) return false
|
||||
return isRef(v) ? v.value : v
|
||||
})
|
||||
|
||||
const resolvedClearDisabledTooltip = computed(() =>
|
||||
resolvedClearDisabled.value ? unwrapMaybeRef(ctx.clearDisabledTooltip) : undefined,
|
||||
)
|
||||
|
||||
function handleFilterToggle(value: LogLevel) {
|
||||
toggleFilter(value)
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
if (isFullscreen.value) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.body.classList.add(fullscreenBodyClass)
|
||||
pageContext?.intercomBubble?.requestHorizontalPadding?.(
|
||||
fullscreenIntercomPaddingRequestId,
|
||||
fullscreenIntercomPadding,
|
||||
)
|
||||
modalBehavior?.onShow?.()
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
document.body.classList.remove(fullscreenBodyClass)
|
||||
pageContext?.intercomBubble?.requestHorizontalPadding?.(
|
||||
fullscreenIntercomPaddingRequestId,
|
||||
null,
|
||||
)
|
||||
modalBehavior?.onHide?.()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (resolvedClearDisabled.value) return
|
||||
ctx.onClear?.()
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
deleteModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!ctx.onDelete) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await ctx.onDelete()
|
||||
deleteModal.value?.hide()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete log file:', err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(consoleMessages.deleteFailedTitle),
|
||||
text: typeof err === 'string' ? err : formatMessage(consoleMessages.unknownError),
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
const predicate = buildCombinedPredicate()
|
||||
const lines = predicate ? ctx.logLines.value.filter(predicate) : ctx.logLines.value
|
||||
const content = lines.map((l) => l.text).join('\n')
|
||||
|
||||
isSharing.value = true
|
||||
try {
|
||||
const result = await shareLogs(client, content)
|
||||
if (result.truncated) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: formatMessage(consoleMessages.shareTruncatedWarning),
|
||||
})
|
||||
}
|
||||
if (result.url) {
|
||||
shareModal.value?.show(result.url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to share logs:', err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(consoleMessages.shareFailedTitle),
|
||||
text: typeof err === 'string' ? err : formatMessage(consoleMessages.unknownError),
|
||||
})
|
||||
} finally {
|
||||
isSharing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.terminal-loading-fade-enter-active,
|
||||
.terminal-loading-fade-leave-active {
|
||||
transition: opacity 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.terminal-loading-fade-enter-from,
|
||||
.terminal-loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modrinth-console-fullscreen-active .intercom-lightweight-app,
|
||||
.modrinth-console-fullscreen-active .intercom-lightweight-app-launcher,
|
||||
.modrinth-console-fullscreen-active .intercom-lightweight-app-messenger,
|
||||
.modrinth-console-fullscreen-active .intercom-launcher-frame,
|
||||
.modrinth-console-fullscreen-active .intercom-messenger-frame,
|
||||
.modrinth-console-fullscreen-active #intercom-container,
|
||||
.modrinth-console-fullscreen-active #intercom-frame,
|
||||
.modrinth-console-fullscreen-active iframe[name='intercom-launcher-frame'],
|
||||
.modrinth-console-fullscreen-active iframe[name='intercom-messenger-frame'] {
|
||||
z-index: 14 !important;
|
||||
}
|
||||
|
||||
.modrinth-console-fullscreen-active .loading-indicator-container,
|
||||
.modrinth-console-fullscreen-active .app-contents::before {
|
||||
z-index: 14 !important;
|
||||
}
|
||||
|
||||
.modrinth-console-fullscreen-active .app-grid-navbar,
|
||||
.modrinth-console-fullscreen-active .app-grid-statusbar {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
</style>
|
||||
560
packages/ui/src/layouts/shared/console/messages.ts
Normal file
560
packages/ui/src/layouts/shared/console/messages.ts
Normal file
@ -0,0 +1,560 @@
|
||||
import { defineMessages } from '#ui/composables/i18n'
|
||||
|
||||
export const consoleMessages = defineMessages({
|
||||
exportCrashContext: {
|
||||
id: 'console.crash.export-context',
|
||||
defaultMessage: 'Export crash context',
|
||||
},
|
||||
searchLogs: { id: 'console.search.placeholder', defaultMessage: 'Search logs' },
|
||||
toggleWrap: { id: 'console.log.toggle-wrap', defaultMessage: 'Toggle line wrapping' },
|
||||
wrapLabel: { id: 'console.log.wrap-label', defaultMessage: 'Wrap' },
|
||||
emptyInstanceTitle: {
|
||||
id: 'console.empty.instance-title',
|
||||
defaultMessage: 'No logs yet',
|
||||
},
|
||||
emptyInstanceDescription: {
|
||||
id: 'console.empty.instance-description',
|
||||
defaultMessage: 'Click the Play button to start receiving live logs.',
|
||||
},
|
||||
emptyServerTitle: {
|
||||
id: 'console.empty.server-title',
|
||||
defaultMessage: 'Welcome to your Modrinth server!',
|
||||
},
|
||||
emptyServerDescription: {
|
||||
id: 'console.empty.server-description',
|
||||
defaultMessage: 'Click the start button to start the server!',
|
||||
},
|
||||
shareLogs: { id: 'console.share-modal.title', defaultMessage: 'Share Logs' },
|
||||
deleteLogFile: { id: 'console.delete-modal.title', defaultMessage: 'Delete log file' },
|
||||
deleteIrreversible: {
|
||||
id: 'console.delete-modal.irreversible-title',
|
||||
defaultMessage: 'This is irreversible',
|
||||
},
|
||||
deleteConfirmation: {
|
||||
id: 'console.delete-modal.confirmation',
|
||||
defaultMessage: 'Deleting this log file cannot be undone. Are you sure you want to continue?',
|
||||
},
|
||||
localCrashHeader: {
|
||||
id: 'console.crash.local-header',
|
||||
defaultMessage:
|
||||
'{findings, plural, one {# local diagnosis result} other {# local diagnosis results}} from {sources, plural, one {# related file} other {# related files}}',
|
||||
},
|
||||
fallbackFindingAction: {
|
||||
id: 'console.crash.finding.fallback-action',
|
||||
defaultMessage: 'Review the evidence below and the Mods matched from the local instance.',
|
||||
},
|
||||
fallbackFindingTitle: {
|
||||
id: 'console.crash.finding.fallback-title',
|
||||
defaultMessage: 'Unknown diagnosis: {finding}',
|
||||
},
|
||||
knownSignatureTitle: {
|
||||
id: 'console.crash.finding.known-signature.title',
|
||||
defaultMessage: 'A known crash signature was detected',
|
||||
},
|
||||
knownSignatureAction: {
|
||||
id: 'console.crash.finding.known-signature.action',
|
||||
defaultMessage:
|
||||
'Review the evidence and the related Minecraft, loader, Java, graphics, or Mod versions before changing the instance.',
|
||||
},
|
||||
matchedMod: {
|
||||
id: 'console.crash.finding.matched-mod',
|
||||
defaultMessage: 'Matched Mod: {identity}{modId} - {fileName}',
|
||||
},
|
||||
modChange: {
|
||||
id: 'console.crash.mod-change',
|
||||
defaultMessage: '{kind}: {filename}',
|
||||
},
|
||||
modChangesTitle: {
|
||||
id: 'console.crash.mod-changes.title',
|
||||
defaultMessage: 'Mod files changed since the last successful launch',
|
||||
},
|
||||
modChangesSummary: {
|
||||
id: 'console.crash.mod-changes.summary',
|
||||
defaultMessage: '{added} added, {removed} removed, {modified} modified',
|
||||
},
|
||||
modChangeAdded: {
|
||||
id: 'console.crash.mod-change.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
modChangeRemoved: {
|
||||
id: 'console.crash.mod-change.removed',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
modChangeModified: {
|
||||
id: 'console.crash.mod-change.modified',
|
||||
defaultMessage: 'Modified',
|
||||
},
|
||||
windowsEventsTitle: {
|
||||
id: 'console.crash.windows-events.title',
|
||||
defaultMessage: 'Related Windows application events',
|
||||
},
|
||||
problemsDetected: {
|
||||
id: 'console.crash.problems-detected',
|
||||
defaultMessage: '{count, plural, one {# problem detected} other {# problems detected}}',
|
||||
},
|
||||
commandInputDisabled: {
|
||||
id: 'console.command.disabled-placeholder',
|
||||
defaultMessage: 'Command input disabled',
|
||||
},
|
||||
commandPlaceholder: {
|
||||
id: 'console.command.placeholder',
|
||||
defaultMessage: 'Send a command',
|
||||
},
|
||||
serverNotRunning: {
|
||||
id: 'console.command.server-not-running-placeholder',
|
||||
defaultMessage: 'Server is not running',
|
||||
},
|
||||
deleteFailedTitle: {
|
||||
id: 'console.notification.delete-failed',
|
||||
defaultMessage: 'Failed to delete log file',
|
||||
},
|
||||
shareFailedTitle: {
|
||||
id: 'console.notification.share-failed',
|
||||
defaultMessage: 'Failed to share logs',
|
||||
},
|
||||
shareTruncatedWarning: {
|
||||
id: 'console.notification.share-truncated',
|
||||
defaultMessage: 'The log is too large, so only the last 9 MB was uploaded.',
|
||||
},
|
||||
unknownError: { id: 'console.notification.unknown-error', defaultMessage: 'Unknown error.' },
|
||||
})
|
||||
|
||||
export const localFindingMessages = defineMessages({
|
||||
jvmArgumentsTitle: {
|
||||
id: 'console.crash.finding.jvm-arguments.title',
|
||||
defaultMessage: 'Invalid JVM arguments',
|
||||
},
|
||||
jvmArgumentsAction: {
|
||||
id: 'console.crash.finding.jvm-arguments.action',
|
||||
defaultMessage: 'Remove the reported custom JVM argument, then launch the instance again.',
|
||||
},
|
||||
outOfMemoryTitle: {
|
||||
id: 'console.crash.finding.out-of-memory.title',
|
||||
defaultMessage: 'Minecraft ran out of memory',
|
||||
},
|
||||
outOfMemoryAction: {
|
||||
id: 'console.crash.finding.out-of-memory.action',
|
||||
defaultMessage:
|
||||
'Increase the instance memory allocation or remove memory-heavy mods and resource packs.',
|
||||
},
|
||||
openglUnsupportedTitle: {
|
||||
id: 'console.crash.finding.opengl-unsupported.title',
|
||||
defaultMessage: 'OpenGL is not supported by the active graphics driver',
|
||||
},
|
||||
openglUnsupportedAction: {
|
||||
id: 'console.crash.finding.opengl-unsupported.action',
|
||||
defaultMessage:
|
||||
'Install the graphics driver from the GPU manufacturer and ensure Minecraft uses the intended GPU.',
|
||||
},
|
||||
pixelFormatTitle: {
|
||||
id: 'console.crash.finding.pixel-format.title',
|
||||
defaultMessage: 'The graphics driver could not set a pixel format',
|
||||
},
|
||||
pixelFormatAction: {
|
||||
id: 'console.crash.finding.pixel-format.action',
|
||||
defaultMessage:
|
||||
'Update or reinstall the graphics driver and disable conflicting overlays before retrying.',
|
||||
},
|
||||
openj9Title: {
|
||||
id: 'console.crash.finding.openj9.title',
|
||||
defaultMessage: 'The selected OpenJ9 runtime is incompatible',
|
||||
},
|
||||
openj9Action: {
|
||||
id: 'console.crash.finding.openj9.action',
|
||||
defaultMessage:
|
||||
'Select a HotSpot-based Java runtime such as Eclipse Temurin or the bundled Minecraft runtime.',
|
||||
},
|
||||
javaTooNewTitle: {
|
||||
id: 'console.crash.finding.java-too-new.title',
|
||||
defaultMessage: 'The Java runtime is too new for this instance',
|
||||
},
|
||||
javaTooNewAction: {
|
||||
id: 'console.crash.finding.java-too-new.action',
|
||||
defaultMessage:
|
||||
'Select the Java major version expected by this Minecraft and mod-loader version.',
|
||||
},
|
||||
javaIncompatibleTitle: {
|
||||
id: 'console.crash.finding.java-incompatible.title',
|
||||
defaultMessage: 'A mod requires a different Java version',
|
||||
},
|
||||
javaIncompatibleAction: {
|
||||
id: 'console.crash.finding.java-incompatible.action',
|
||||
defaultMessage:
|
||||
'Use a compatible Java runtime or install a build of the reported mod for this Java version.',
|
||||
},
|
||||
jdkRuntimeTitle: {
|
||||
id: 'console.crash.finding.jdk-runtime.title',
|
||||
defaultMessage: 'A JDK runtime was selected instead of a JRE',
|
||||
},
|
||||
jdkRuntimeAction: {
|
||||
id: 'console.crash.finding.jdk-runtime.action',
|
||||
defaultMessage: 'Select a standard HotSpot Java runtime for this Minecraft version.',
|
||||
},
|
||||
java32BitTitle: {
|
||||
id: 'console.crash.finding.java-32bit.title',
|
||||
defaultMessage: 'A 32-bit Java runtime cannot allocate the requested memory',
|
||||
},
|
||||
java32BitAction: {
|
||||
id: 'console.crash.finding.java-32bit.action',
|
||||
defaultMessage: 'Install and select a 64-bit Java runtime, then retry the launch.',
|
||||
},
|
||||
java11RequiredTitle: {
|
||||
id: 'console.crash.finding.java-11-required.title',
|
||||
defaultMessage: 'A Mod requires Java 11',
|
||||
},
|
||||
java11RequiredAction: {
|
||||
id: 'console.crash.finding.java-11-required.action',
|
||||
defaultMessage:
|
||||
'Select Java 11 or install a Mod build compatible with the selected Java version.',
|
||||
},
|
||||
forgeIncompleteTitle: {
|
||||
id: 'console.crash.finding.forge-incomplete.title',
|
||||
defaultMessage: 'The Forge installation is incomplete',
|
||||
},
|
||||
forgeIncompleteAction: {
|
||||
id: 'console.crash.finding.forge-incomplete.action',
|
||||
defaultMessage: 'Repair or reinstall the Forge loader for this instance.',
|
||||
},
|
||||
duplicateModTitle: {
|
||||
id: 'console.crash.finding.duplicate-mod.title',
|
||||
defaultMessage: 'Duplicate Mods are installed',
|
||||
},
|
||||
duplicateModAction: {
|
||||
id: 'console.crash.finding.duplicate-mod.action',
|
||||
defaultMessage: 'Keep only one compatible version of each Mod in the mods folder.',
|
||||
},
|
||||
incompatibleModsTitle: {
|
||||
id: 'console.crash.finding.incompatible-mods.title',
|
||||
defaultMessage: 'The installed Mods are incompatible',
|
||||
},
|
||||
incompatibleModsAction: {
|
||||
id: 'console.crash.finding.incompatible-mods.action',
|
||||
defaultMessage:
|
||||
'Follow the compatibility details in the evidence and update, remove, or replace the conflicting Mods.',
|
||||
},
|
||||
missingDependencyTitle: {
|
||||
id: 'console.crash.finding.missing-dependency.title',
|
||||
defaultMessage: 'A Mod dependency is missing or unsupported',
|
||||
},
|
||||
missingDependencyAction: {
|
||||
id: 'console.crash.finding.missing-dependency.action',
|
||||
defaultMessage:
|
||||
'Install the required dependency version or use a Mod build matching this Minecraft version.',
|
||||
},
|
||||
diskSpaceTitle: {
|
||||
id: 'console.crash.finding.disk-space.title',
|
||||
defaultMessage: 'The disk ran out of free space',
|
||||
},
|
||||
diskSpaceAction: {
|
||||
id: 'console.crash.finding.disk-space.action',
|
||||
defaultMessage:
|
||||
'Free space on the drive containing the instance, then retry the launch or installation.',
|
||||
},
|
||||
fileInUseTitle: {
|
||||
id: 'console.crash.finding.file-in-use.title',
|
||||
defaultMessage: 'Another process is using a required file',
|
||||
},
|
||||
fileInUseAction: {
|
||||
id: 'console.crash.finding.file-in-use.action',
|
||||
defaultMessage:
|
||||
'Close the program named in the evidence, including other launchers, backup tools, or antivirus scans, then retry.',
|
||||
},
|
||||
connectorIncompatibleFabricModsTitle: {
|
||||
id: 'console.crash.finding.connector-incompatible-fabric-mods.title',
|
||||
defaultMessage: 'Sinytra Connector found incompatible Fabric Mods',
|
||||
},
|
||||
connectorIncompatibleFabricModsAction: {
|
||||
id: 'console.crash.finding.connector-incompatible-fabric-mods.action',
|
||||
defaultMessage:
|
||||
'Remove or replace the Fabric Mods named in the Connector error with Forge-compatible alternatives.',
|
||||
},
|
||||
missingEmbeddiumTitle: {
|
||||
id: 'console.crash.finding.missing-embeddium.title',
|
||||
defaultMessage: 'Oculus requires Embeddium',
|
||||
},
|
||||
missingEmbeddiumAction: {
|
||||
id: 'console.crash.finding.missing-embeddium.action',
|
||||
defaultMessage:
|
||||
'Install the Embeddium version required by Oculus for this Minecraft and Forge version.',
|
||||
},
|
||||
missingIndiumTitle: {
|
||||
id: 'console.crash.finding.missing-indium.title',
|
||||
defaultMessage: 'A Mod requires Indium',
|
||||
},
|
||||
missingIndiumAction: {
|
||||
id: 'console.crash.finding.missing-indium.action',
|
||||
defaultMessage:
|
||||
'Install the Indium version compatible with the installed Fabric Loader and Sodium version.',
|
||||
},
|
||||
modIdLimitTitle: {
|
||||
id: 'console.crash.finding.mod-id-limit.title',
|
||||
defaultMessage: 'Too many Mods exceeded the ID limit',
|
||||
},
|
||||
modIdLimitAction: {
|
||||
id: 'console.crash.finding.mod-id-limit.action',
|
||||
defaultMessage:
|
||||
'Remove unused Mods or split the installation into smaller compatible profiles.',
|
||||
},
|
||||
forgeErrorTitle: {
|
||||
id: 'console.crash.finding.forge-error.title',
|
||||
defaultMessage: 'Forge reported a game error',
|
||||
},
|
||||
forgeErrorAction: {
|
||||
id: 'console.crash.finding.forge-error.action',
|
||||
defaultMessage:
|
||||
'Review the Forge failure evidence and test the named Mod without recent changes.',
|
||||
},
|
||||
modLoaderErrorTitle: {
|
||||
id: 'console.crash.finding.mod-loader-error.title',
|
||||
defaultMessage: 'The Mod loader reported a failure',
|
||||
},
|
||||
modLoaderErrorAction: {
|
||||
id: 'console.crash.finding.mod-loader-error.action',
|
||||
defaultMessage:
|
||||
'Repair the loader installation and verify that the listed Mod files match this game version.',
|
||||
},
|
||||
modLoaderFailureTitle: {
|
||||
id: 'console.crash.finding.mod-loader-failure.title',
|
||||
defaultMessage: 'The Mod loader failed before identifying a Mod file',
|
||||
},
|
||||
modLoaderFailureAction: {
|
||||
id: 'console.crash.finding.mod-loader-failure.action',
|
||||
defaultMessage:
|
||||
'Repair the loader installation and follow the failure message shown in the evidence.',
|
||||
},
|
||||
stackAnalysisTitle: {
|
||||
id: 'console.crash.finding.stack-analysis.title',
|
||||
defaultMessage: 'The stack trace points to an installed Mod',
|
||||
},
|
||||
stackAnalysisAction: {
|
||||
id: 'console.crash.finding.stack-analysis.action',
|
||||
defaultMessage: 'Update or temporarily remove the matched Mod, then test the instance again.',
|
||||
},
|
||||
shortOutputTitle: {
|
||||
id: 'console.crash.finding.short-output.title',
|
||||
defaultMessage: 'The game stopped before producing a useful log',
|
||||
},
|
||||
shortOutputAction: {
|
||||
id: 'console.crash.finding.short-output.action',
|
||||
defaultMessage:
|
||||
'Retry once, then verify Java, the loader installation, and the launcher output for an earlier error.',
|
||||
},
|
||||
extractedModTitle: {
|
||||
id: 'console.crash.finding.extracted-mod.title',
|
||||
defaultMessage: 'An extracted Mod was found',
|
||||
},
|
||||
extractedModAction: {
|
||||
id: 'console.crash.finding.extracted-mod.action',
|
||||
defaultMessage:
|
||||
'Remove the extracted directory from the mods folder and install the original jar file.',
|
||||
},
|
||||
mixinBootstrapTitle: {
|
||||
id: 'console.crash.finding.mixin-bootstrap.title',
|
||||
defaultMessage: 'Mixin bootstrap is missing',
|
||||
},
|
||||
mixinBootstrapAction: {
|
||||
id: 'console.crash.finding.mixin-bootstrap.action',
|
||||
defaultMessage:
|
||||
'Repair the mod loader installation and verify that every mod targets the installed loader.',
|
||||
},
|
||||
mixinFailureTitle: {
|
||||
id: 'console.crash.finding.mixin-failure.title',
|
||||
defaultMessage: 'A Mod Mixin failed to apply',
|
||||
},
|
||||
mixinFailureAction: {
|
||||
id: 'console.crash.finding.mixin-failure.action',
|
||||
defaultMessage:
|
||||
'Update or remove the matched Mod and check that its Minecraft and loader versions are compatible.',
|
||||
},
|
||||
fabricSolutionTitle: {
|
||||
id: 'console.crash.finding.fabric-solution.title',
|
||||
defaultMessage: 'Fabric found an incompatible Mod or missing dependency',
|
||||
},
|
||||
fabricSolutionAction: {
|
||||
id: 'console.crash.finding.fabric-solution.action',
|
||||
defaultMessage: 'Apply the dependency changes listed in the evidence before launching again.',
|
||||
},
|
||||
modConfigTitle: {
|
||||
id: 'console.crash.finding.mod-config.title',
|
||||
defaultMessage: 'A Mod configuration file could not be read',
|
||||
},
|
||||
modConfigAction: {
|
||||
id: 'console.crash.finding.mod-config.action',
|
||||
defaultMessage: 'Back up and remove the named configuration file so the Mod can regenerate it.',
|
||||
},
|
||||
optifineIncompatibleTitle: {
|
||||
id: 'console.crash.finding.optifine-incompatible.title',
|
||||
defaultMessage: 'OptiFine conflicts with the installed loader or Mod',
|
||||
},
|
||||
optifineIncompatibleAction: {
|
||||
id: 'console.crash.finding.optifine-incompatible.action',
|
||||
defaultMessage:
|
||||
'Install a compatible OptiFine build or remove OptiFine and the conflicting shader Mod.',
|
||||
},
|
||||
resourcePackTitle: {
|
||||
id: 'console.crash.finding.resource-pack.title',
|
||||
defaultMessage: 'A shader or resource pack triggered a graphics error',
|
||||
},
|
||||
resourcePackAction: {
|
||||
id: 'console.crash.finding.resource-pack.action',
|
||||
defaultMessage:
|
||||
'Disable the active shader and resource packs, then re-enable them one at a time.',
|
||||
},
|
||||
largeResourcePackTitle: {
|
||||
id: 'console.crash.finding.large-resource-pack.title',
|
||||
defaultMessage: 'The active resource pack is too large for the graphics configuration',
|
||||
},
|
||||
largeResourcePackAction: {
|
||||
id: 'console.crash.finding.large-resource-pack.action',
|
||||
defaultMessage: 'Disable the resource pack or use a lower-resolution version.',
|
||||
},
|
||||
shadersOptifineTitle: {
|
||||
id: 'console.crash.finding.shaders-optifine.title',
|
||||
defaultMessage: 'Shaders Mod and OptiFine are installed together',
|
||||
},
|
||||
shadersOptifineAction: {
|
||||
id: 'console.crash.finding.shaders-optifine.action',
|
||||
defaultMessage:
|
||||
'Remove the separate Shaders Mod because OptiFine already provides shader support.',
|
||||
},
|
||||
multipleForgeVersionsTitle: {
|
||||
id: 'console.crash.finding.multiple-forge-versions.title',
|
||||
defaultMessage: 'The version profile contains multiple Forge versions',
|
||||
},
|
||||
multipleForgeVersionsAction: {
|
||||
id: 'console.crash.finding.multiple-forge-versions.action',
|
||||
defaultMessage:
|
||||
'Repair the instance so its version profile contains only one Forge installation.',
|
||||
},
|
||||
forgeJavaIncompatibleTitle: {
|
||||
id: 'console.crash.finding.forge-java-incompatible.title',
|
||||
defaultMessage: 'This Forge version is incompatible with the selected Java runtime',
|
||||
},
|
||||
forgeJavaIncompatibleAction: {
|
||||
id: 'console.crash.finding.forge-java-incompatible.action',
|
||||
defaultMessage: 'Use the Java version expected by this Forge release or update Forge.',
|
||||
},
|
||||
contentVerificationTitle: {
|
||||
id: 'console.crash.finding.content-verification.title',
|
||||
defaultMessage: 'A jar failed signature verification',
|
||||
},
|
||||
contentVerificationAction: {
|
||||
id: 'console.crash.finding.content-verification.action',
|
||||
defaultMessage: 'Remove and reinstall the file named in the evidence from a trusted source.',
|
||||
},
|
||||
optifineWorldTitle: {
|
||||
id: 'console.crash.finding.optifine-world.title',
|
||||
defaultMessage: 'OptiFine prevented the world from loading',
|
||||
},
|
||||
optifineWorldAction: {
|
||||
id: 'console.crash.finding.optifine-world.action',
|
||||
defaultMessage:
|
||||
'Remove OptiFine or install a build compatible with this Minecraft and Forge version.',
|
||||
},
|
||||
nightconfigBugTitle: {
|
||||
id: 'console.crash.finding.nightconfig-bug.title',
|
||||
defaultMessage: 'NightConfig could not read a configuration file',
|
||||
},
|
||||
nightconfigBugAction: {
|
||||
id: 'console.crash.finding.nightconfig-bug.action',
|
||||
defaultMessage:
|
||||
'Back up the config folder, remove the damaged configuration, and let the Mod regenerate it.',
|
||||
},
|
||||
modFilenameTitle: {
|
||||
id: 'console.crash.finding.mod-filename.title',
|
||||
defaultMessage: 'A Mod filename contains unsupported characters',
|
||||
},
|
||||
modFilenameAction: {
|
||||
id: 'console.crash.finding.mod-filename.action',
|
||||
defaultMessage: 'Rename or reinstall the Mod jar using a simple Latin-letter filename.',
|
||||
},
|
||||
definiteModTitle: {
|
||||
id: 'console.crash.finding.definite-mod.title',
|
||||
defaultMessage: 'A specific Mod caused the crash',
|
||||
},
|
||||
definiteModAction: {
|
||||
id: 'console.crash.finding.definite-mod.action',
|
||||
defaultMessage:
|
||||
'Update, repair, or temporarily remove the Mod identified by the evidence and matched jar.',
|
||||
},
|
||||
definiteModFabricTitle: {
|
||||
id: 'console.crash.finding.definite-mod-fabric.title',
|
||||
defaultMessage: 'Fabric identified a specific Mod failure',
|
||||
},
|
||||
definiteModFabricAction: {
|
||||
id: 'console.crash.finding.definite-mod-fabric.action',
|
||||
defaultMessage:
|
||||
'Update or temporarily remove the Mod identified by the Fabric loader evidence.',
|
||||
},
|
||||
intelDriverTitle: {
|
||||
id: 'console.crash.finding.intel-driver.title',
|
||||
defaultMessage: 'The Intel graphics driver crashed',
|
||||
},
|
||||
intelDriverAction: {
|
||||
id: 'console.crash.finding.intel-driver.action',
|
||||
defaultMessage:
|
||||
'Install a current Intel graphics driver or run Minecraft on another available GPU.',
|
||||
},
|
||||
amdDriverTitle: {
|
||||
id: 'console.crash.finding.amd-driver.title',
|
||||
defaultMessage: 'The AMD graphics driver crashed',
|
||||
},
|
||||
amdDriverAction: {
|
||||
id: 'console.crash.finding.amd-driver.action',
|
||||
defaultMessage:
|
||||
'Clean-install a current AMD graphics driver and retry without graphics overlays.',
|
||||
},
|
||||
nvidiaDriverTitle: {
|
||||
id: 'console.crash.finding.nvidia-driver.title',
|
||||
defaultMessage: 'The NVIDIA graphics driver crashed',
|
||||
},
|
||||
nvidiaDriverAction: {
|
||||
id: 'console.crash.finding.nvidia-driver.action',
|
||||
defaultMessage:
|
||||
'Clean-install a current NVIDIA graphics driver and retry without graphics overlays.',
|
||||
},
|
||||
manualDebugCrashTitle: {
|
||||
id: 'console.crash.finding.manual-debug-crash.title',
|
||||
defaultMessage: 'The debug crash shortcut was triggered',
|
||||
},
|
||||
manualDebugCrashAction: {
|
||||
id: 'console.crash.finding.manual-debug-crash.action',
|
||||
defaultMessage: 'Launch again and avoid holding the manual debug-crash key combination.',
|
||||
},
|
||||
suspectedModTitle: {
|
||||
id: 'console.crash.finding.suspected-mod.title',
|
||||
defaultMessage: 'The crash report suspects one or more Mods',
|
||||
},
|
||||
suspectedModAction: {
|
||||
id: 'console.crash.finding.suspected-mod.action',
|
||||
defaultMessage:
|
||||
'Update or temporarily remove the suspected and locally matched Mods, then retry.',
|
||||
},
|
||||
modInitializationTitle: {
|
||||
id: 'console.crash.finding.mod-initialization.title',
|
||||
defaultMessage: 'A Mod failed to initialize',
|
||||
},
|
||||
modInitializationAction: {
|
||||
id: 'console.crash.finding.mod-initialization.action',
|
||||
defaultMessage:
|
||||
'Update the named Mod and verify that all of its required dependencies are installed.',
|
||||
},
|
||||
specificBlockTitle: {
|
||||
id: 'console.crash.finding.specific-block.title',
|
||||
defaultMessage: 'A specific block caused the crash',
|
||||
},
|
||||
specificBlockAction: {
|
||||
id: 'console.crash.finding.specific-block.action',
|
||||
defaultMessage:
|
||||
'Use a world backup or a world editor to remove the block at the coordinates in the evidence.',
|
||||
},
|
||||
specificEntityTitle: {
|
||||
id: 'console.crash.finding.specific-entity.title',
|
||||
defaultMessage: 'A specific entity caused the crash',
|
||||
},
|
||||
specificEntityAction: {
|
||||
id: 'console.crash.finding.specific-entity.action',
|
||||
defaultMessage:
|
||||
'Use a world backup or a world editor to remove the entity at the coordinates in the evidence.',
|
||||
},
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type { LogLine, LogSource } from '../types'
|
||||
|
||||
export interface LocalCrashAnalysis {
|
||||
crashed: boolean
|
||||
sources: Array<{ filename: string; source_type: string; line_count: number }>
|
||||
findings: Array<{
|
||||
id: string
|
||||
confidence: string
|
||||
evidence: Array<{ filename: string; line: number; text: string }>
|
||||
}>
|
||||
mods: Array<{
|
||||
file_name: string
|
||||
id?: string
|
||||
name?: string
|
||||
matched_class?: string
|
||||
}>
|
||||
mod_changes: Array<{
|
||||
kind: 'added' | 'removed' | 'modified'
|
||||
filename: string
|
||||
previous_size?: number
|
||||
current_size?: number
|
||||
current_sha256?: string
|
||||
project_id?: string
|
||||
project_title?: string
|
||||
icon_url?: string
|
||||
version_id?: string
|
||||
version_number?: string
|
||||
}>
|
||||
mod_change_counts: { added: number; removed: number; modified: number }
|
||||
windows_events: Array<{
|
||||
event_id: number
|
||||
provider: string
|
||||
time_created: string
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ConsoleManagerContext {
|
||||
logLines: Ref<LogLine[]>
|
||||
|
||||
logSources?: ComputedRef<LogSource[]>
|
||||
activeLogSourceIndex?: Ref<number>
|
||||
|
||||
sendCommand?: (cmd: string) => void
|
||||
showCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
|
||||
disableCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
|
||||
disableCommandInputTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
|
||||
|
||||
loading?: Ref<boolean> | ComputedRef<boolean>
|
||||
|
||||
onClear?: () => void
|
||||
clearDisabled?: Ref<boolean> | ComputedRef<boolean>
|
||||
clearDisabledTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
|
||||
onDelete?: () => Promise<void>
|
||||
deleteDisabled?: Ref<boolean> | ComputedRef<boolean>
|
||||
deleteDisabledTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
|
||||
|
||||
shareDisabled?: Ref<boolean> | ComputedRef<boolean>
|
||||
|
||||
emptyStateType?: 'server' | 'instance'
|
||||
|
||||
localCrashAnalysis?: Ref<LocalCrashAnalysis | null>
|
||||
crashAnalysisLoading?: Ref<boolean>
|
||||
onExportCrashContext?: () => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectConsoleManager, provideConsoleManager] = createContext<ConsoleManagerContext>(
|
||||
'ConsolePageLayout',
|
||||
'consoleManagerContext',
|
||||
)
|
||||
@ -0,0 +1 @@
|
||||
export * from './console-manager'
|
||||
21
packages/ui/src/layouts/shared/console/types.ts
Normal file
21
packages/ui/src/layouts/shared/console/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'
|
||||
|
||||
export interface LogLine {
|
||||
text: string
|
||||
level: LogLevel | null
|
||||
}
|
||||
|
||||
export interface Log4jEvent {
|
||||
logger_name?: string
|
||||
level?: string
|
||||
thread_name?: string
|
||||
timestamp_millis?: number
|
||||
message?: string
|
||||
throwable?: string
|
||||
}
|
||||
|
||||
export interface LogSource {
|
||||
id: string
|
||||
name: string
|
||||
live: boolean
|
||||
}
|
||||
@ -0,0 +1,793 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
DownloadIcon,
|
||||
MoreVerticalIcon,
|
||||
SkullIcon,
|
||||
SpinnerIcon,
|
||||
TrashExclamationIcon,
|
||||
TrashIcon,
|
||||
TriangleAlertIcon,
|
||||
UndoIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { autoCleanToText } from '@sfirew/minecraft-motd-parser'
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { computed, getCurrentInstance, ref } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import MinecraftFormattedText from '#ui/components/base/MinecraftFormattedText.vue'
|
||||
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import Toggle from '#ui/components/base/Toggle.vue'
|
||||
import { useRelativeTime } from '#ui/composables/how-ago'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { truncatedTooltip } from '#ui/utils/truncate'
|
||||
|
||||
import type {
|
||||
ClientWarningType,
|
||||
ContentCardProject,
|
||||
ContentCardVersion,
|
||||
ContentOwner,
|
||||
ContentRowInlineAction,
|
||||
ContentWorldGroupMeta,
|
||||
} from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
selectProject: {
|
||||
id: 'content.card.select-project',
|
||||
defaultMessage: 'Select {project}',
|
||||
},
|
||||
pendingManualDownload: {
|
||||
id: 'content.card.pending-manual-download',
|
||||
defaultMessage: 'Manual download required',
|
||||
},
|
||||
duplicateMod: {
|
||||
id: 'content.card.duplicate-mod',
|
||||
defaultMessage: 'This mod is installed {count, number} times.',
|
||||
},
|
||||
rollbackTooltip: {
|
||||
id: 'content.card.rollback-tooltip',
|
||||
defaultMessage: 'Roll back to {fileName}',
|
||||
},
|
||||
dependencyBadge: {
|
||||
id: 'content.card.dependency-badge',
|
||||
defaultMessage: 'Dependency',
|
||||
},
|
||||
orphanedDependencyBadge: {
|
||||
id: 'content.card.orphaned-dependency-badge',
|
||||
defaultMessage: 'Orphaned dependency',
|
||||
},
|
||||
notPlayedYet: {
|
||||
id: 'content.card.group.not-played-yet',
|
||||
defaultMessage: 'Not played yet',
|
||||
},
|
||||
hardcore: {
|
||||
id: 'content.card.group.hardcore',
|
||||
defaultMessage: 'Hardcore mode',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
project: ContentCardProject
|
||||
projectLink?: string | RouteLocationRaw
|
||||
version?: ContentCardVersion
|
||||
versionLink?: string | RouteLocationRaw
|
||||
owner?: ContentOwner
|
||||
enabled?: boolean
|
||||
installing?: boolean
|
||||
pendingManualDownload?: boolean
|
||||
duplicateCount?: number
|
||||
hasUpdate?: boolean
|
||||
rollbackFileName?: string
|
||||
isClientOnly?: boolean
|
||||
clientWarning?: ClientWarningType | null
|
||||
hideSwitchVersion?: boolean
|
||||
overflowOptions?: OverflowMenuOption[]
|
||||
inlineActions?: ContentRowInlineAction[]
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string | null
|
||||
postUpgradeWarningTooltip?: string | null
|
||||
toggleDisabled?: boolean
|
||||
toggleDisabledTooltip?: string | null
|
||||
dependencyBadge?: {
|
||||
autoDependency: boolean
|
||||
orphaned: boolean
|
||||
} | null
|
||||
showCheckbox?: boolean
|
||||
hideDelete?: boolean
|
||||
hideActions?: boolean
|
||||
inline?: boolean
|
||||
isGroupHeader?: boolean
|
||||
groupDepth?: number
|
||||
groupItemCount?: number
|
||||
groupExpanded?: boolean
|
||||
groupSwitchVersion?: () => void
|
||||
isGroupChild?: boolean
|
||||
groupKind?: 'folder' | 'world'
|
||||
groupMeta?: ContentWorldGroupMeta
|
||||
groupCheckboxIndeterminate?: boolean
|
||||
downloads?: number | null
|
||||
categories?: Array<{
|
||||
name: string
|
||||
icon?: string
|
||||
action?: (event: MouseEvent) => void
|
||||
}>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
projectLink: undefined,
|
||||
version: undefined,
|
||||
versionLink: undefined,
|
||||
owner: undefined,
|
||||
enabled: undefined,
|
||||
installing: false,
|
||||
pendingManualDownload: false,
|
||||
duplicateCount: undefined,
|
||||
hasUpdate: false,
|
||||
rollbackFileName: undefined,
|
||||
isClientOnly: false,
|
||||
clientWarning: null,
|
||||
hideSwitchVersion: false,
|
||||
overflowOptions: undefined,
|
||||
inlineActions: undefined,
|
||||
disabled: false,
|
||||
disabledTooltip: undefined,
|
||||
postUpgradeWarningTooltip: undefined,
|
||||
toggleDisabled: false,
|
||||
toggleDisabledTooltip: undefined,
|
||||
dependencyBadge: null,
|
||||
showCheckbox: false,
|
||||
hideDelete: false,
|
||||
hideActions: false,
|
||||
inline: false,
|
||||
isGroupHeader: false,
|
||||
groupDepth: 0,
|
||||
groupItemCount: 0,
|
||||
groupExpanded: false,
|
||||
groupSwitchVersion: undefined,
|
||||
isGroupChild: false,
|
||||
groupKind: 'folder',
|
||||
groupMeta: undefined,
|
||||
groupCheckboxIndeterminate: false,
|
||||
downloads: null,
|
||||
categories: undefined,
|
||||
})
|
||||
|
||||
const selected = defineModel<boolean>('selected')
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean]
|
||||
select: [value: boolean, event?: MouseEvent]
|
||||
delete: [event: MouseEvent]
|
||||
update: []
|
||||
switchVersion: []
|
||||
rollback: []
|
||||
toggleExpand: []
|
||||
}>()
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function')
|
||||
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
|
||||
const hasRollbackListener = computed(() => typeof instance?.vnode.props?.onRollback === 'function')
|
||||
const hasSwitchVersionListener = computed(
|
||||
() => typeof instance?.vnode.props?.onSwitchVersion === 'function',
|
||||
)
|
||||
|
||||
const formatCompact = (n: number | undefined | null) => {
|
||||
if (n == null) return ''
|
||||
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 }).format(n)
|
||||
}
|
||||
|
||||
const formatTimeAgo = useRelativeTime()
|
||||
|
||||
const versionNumberRef = ref<HTMLElement | null>(null)
|
||||
const fileNameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const isDisabled = computed(() => props.disabled || props.installing)
|
||||
const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled)
|
||||
const plainProjectTitle = computed(() => autoCleanToText(props.project.title))
|
||||
|
||||
const interactiveSelectors = 'a, button, input, select, [role="checkbox"], [role="button"]'
|
||||
|
||||
function handleRowClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest(interactiveSelectors)) return
|
||||
emit('toggleExpand')
|
||||
}
|
||||
|
||||
const { shift: shiftHeld } = useMagicKeys()
|
||||
const deleteHovered = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isGroupHeader"
|
||||
role="row"
|
||||
class="flex h-[74px] cursor-pointer items-center justify-between gap-4"
|
||||
:class="[
|
||||
{ 'opacity-50': disabled },
|
||||
groupKind === 'world'
|
||||
? selected
|
||||
? 'card-shadow !bg-surface-2.5 rounded-lg p-3'
|
||||
: 'card-shadow !bg-bg-raised rounded-lg p-3 hover:!bg-bg-raised'
|
||||
: 'px-3 hover:bg-[hsl(230deg,6.98%,16.86%,60%)]',
|
||||
]"
|
||||
:style="
|
||||
groupDepth && groupKind !== 'world' ? { paddingLeft: `${groupDepth * 2.5}rem` } : undefined
|
||||
"
|
||||
@click="handleRowClick"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="showCheckbox"
|
||||
:model-value="selected ?? false"
|
||||
:indeterminate="groupCheckboxIndeterminate"
|
||||
:aria-label="formatMessage(messages.selectProject, { project: plainProjectTitle })"
|
||||
class="shrink-0"
|
||||
@update:model-value="(value, event) => emit('select', value, event)"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
:src="project.icon_url"
|
||||
:alt="plainProjectTitle"
|
||||
size="3rem"
|
||||
no-shadow
|
||||
class="rounded-2xl border border-surface-5"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<TriangleAlertIcon
|
||||
v-if="postUpgradeWarningTooltip"
|
||||
v-tooltip="postUpgradeWarningTooltip"
|
||||
class="size-4 shrink-0 text-brand-orange"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<AutoLink
|
||||
:target="
|
||||
typeof projectLink === 'string' && projectLink.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="projectLink"
|
||||
class="truncate text-contrast !decoration-contrast"
|
||||
:class="[
|
||||
groupKind === 'world' ? 'text-lg font-bold' : 'font-semibold leading-6',
|
||||
{ 'hover:underline': projectLink },
|
||||
]"
|
||||
>
|
||||
<MinecraftFormattedText :text="project.title" />
|
||||
</AutoLink>
|
||||
<span
|
||||
v-if="groupKind === 'world'"
|
||||
class="flex items-center gap-1 whitespace-nowrap text-sm font-semibold text-secondary"
|
||||
>
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 shrink-0 text-secondary"
|
||||
stroke-width="3px"
|
||||
/>
|
||||
{{ formatMessage(commonMessages.singleplayerLabel) }}
|
||||
</span>
|
||||
<span class="shrink-0 text-sm font-medium text-secondary">
|
||||
({{ groupItemCount }})
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<template v-if="groupKind === 'world'">
|
||||
<template v-if="groupMeta?.last_played">
|
||||
<ClockIcon class="size-4 shrink-0 text-secondary" />
|
||||
<span class="truncate text-sm leading-5 text-secondary">
|
||||
{{
|
||||
formatMessage(commonMessages.playedLabel, {
|
||||
ago: formatTimeAgo(new Date(groupMeta.last_played)),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="truncate text-sm leading-5 text-secondary">
|
||||
{{ formatMessage(messages.notPlayedYet) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AutoLink
|
||||
v-if="owner"
|
||||
:target="
|
||||
typeof owner.link === 'string' && owner.link.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="owner.link"
|
||||
class="flex shrink-0 items-center gap-1 !decoration-secondary"
|
||||
:class="{ 'hover:underline': owner.link }"
|
||||
>
|
||||
<Avatar
|
||||
:src="owner.avatar_url"
|
||||
:alt="owner.name"
|
||||
size="1.5rem"
|
||||
:circle="owner.type === 'user'"
|
||||
no-shadow
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="text-sm leading-5 text-secondary">{{ owner.name }}</span>
|
||||
</AutoLink>
|
||||
<template v-if="version">
|
||||
<BulletDivider class="shrink-0 @[800px]:hidden" />
|
||||
<AutoLink
|
||||
:target="
|
||||
typeof versionLink === 'string' && versionLink.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="versionLink"
|
||||
class="truncate text-sm leading-5 text-secondary !decoration-secondary @[800px]:hidden"
|
||||
:class="{ 'hover:underline': versionLink }"
|
||||
>
|
||||
{{ version.version_number }}
|
||||
</AutoLink>
|
||||
<template v-if="version.date_published">
|
||||
<BulletDivider class="shrink-0 @[800px]:hidden" />
|
||||
<ClockIcon class="size-4 shrink-0 text-secondary @[800px]:hidden" />
|
||||
<span class="shrink-0 text-sm leading-5 text-secondary @[800px]:hidden">
|
||||
{{ formatTimeAgo(new Date(version.date_published)) }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden flex-col gap-0.5 @[800px]:flex"
|
||||
:class="hideActions ? 'flex-1' : 'flex-1 min-w-0'"
|
||||
>
|
||||
<template v-if="groupKind === 'world'">
|
||||
<div class="flex min-w-0 items-center gap-1.5 font-medium leading-6 text-contrast">
|
||||
<template v-if="groupMeta?.hardcore">
|
||||
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0 text-red" />
|
||||
<span class="text-red">{{ formatMessage(messages.hardcore) }}</span>
|
||||
</template>
|
||||
<span v-else-if="groupMeta?.game_mode" class="text-secondary">
|
||||
{{ groupMeta.game_mode.charAt(0).toUpperCase() + groupMeta.game_mode.slice(1) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="version">
|
||||
<div class="flex min-w-0 items-center gap-1.5 font-medium leading-6 text-contrast">
|
||||
<AutoLink
|
||||
:target="
|
||||
typeof versionLink === 'string' && versionLink.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="versionLink"
|
||||
class="truncate self-start !decoration-contrast"
|
||||
:class="{ 'hover:underline': versionLink, 'cursor-pointer': versionLink }"
|
||||
>
|
||||
{{ version.version_number }}
|
||||
</AutoLink>
|
||||
<template v-if="version.date_published">
|
||||
<ClockIcon class="hidden size-4 shrink-0 text-secondary @[600px]:inline" />
|
||||
<span
|
||||
class="hidden shrink-0 text-sm font-normal leading-6 text-secondary @[600px]:inline"
|
||||
>
|
||||
{{ formatTimeAgo(new Date(version.date_published)) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="flex min-w-0 leading-6 text-secondary">
|
||||
<span class="truncate">{{ version.file_name }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<div v-if="downloads != null" class="flex flex-nowrap items-center gap-3 overflow-hidden">
|
||||
<div v-if="downloads != null" class="flex items-center gap-2 text-secondary">
|
||||
<DownloadIcon class="size-4" />
|
||||
<span class="text-sm font-medium">{{ formatCompact(downloads) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideActions" class="flex min-w-[160px] shrink-0 items-center justify-end gap-2">
|
||||
<template v-if="groupKind !== 'world'">
|
||||
<ButtonStyled
|
||||
v-if="hasUpdate"
|
||||
circular
|
||||
type="transparent"
|
||||
color="green"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
isDisabled && disabledTooltip
|
||||
? disabledTooltip
|
||||
: formatMessage(commonMessages.updateAvailableLabel)
|
||||
"
|
||||
:disabled="isDisabled"
|
||||
@click.stop="emit('update')"
|
||||
>
|
||||
<DownloadIcon class="size-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="groupSwitchVersion" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.switchVersionButton)"
|
||||
@click.stop="groupSwitchVersion"
|
||||
>
|
||||
<ArrowLeftRightIcon class="size-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
class="flex items-center text-secondary hover:text-primary transition-colors"
|
||||
@click.stop="emit('toggleExpand')"
|
||||
>
|
||||
<ChevronDownIcon v-if="groupExpanded" class="size-5" />
|
||||
<ChevronRightIcon v-else class="size-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
role="row"
|
||||
class="flex items-center justify-between"
|
||||
:class="{
|
||||
'h-[74px] gap-4 px-3': !inline,
|
||||
'gap-3': inline,
|
||||
'opacity-50 grayscale': disabled && !installing,
|
||||
'opacity-50': installing,
|
||||
'pl-10': isGroupChild && !inline,
|
||||
}"
|
||||
:style="
|
||||
isGroupChild && !inline && groupDepth > 1
|
||||
? { paddingLeft: `${groupDepth * 2.5}rem` }
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="showCheckbox"
|
||||
:model-value="selected ?? false"
|
||||
:aria-label="formatMessage(messages.selectProject, { project: plainProjectTitle })"
|
||||
class="shrink-0"
|
||||
@update:model-value="(value, event) => emit('select', value, event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-3 transition-[filter,opacity] duration-200"
|
||||
:class="enabled === false && !disabled ? 'grayscale opacity-50' : ''"
|
||||
>
|
||||
<div
|
||||
v-tooltip="
|
||||
installing
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: pendingManualDownload
|
||||
? formatMessage(messages.pendingManualDownload)
|
||||
: undefined
|
||||
"
|
||||
class="relative flex shrink-0 items-center"
|
||||
>
|
||||
<Avatar
|
||||
:src="project.icon_url"
|
||||
:alt="plainProjectTitle"
|
||||
size="3rem"
|
||||
no-shadow
|
||||
class="rounded-2xl border border-surface-5"
|
||||
/>
|
||||
<div
|
||||
v-if="installing"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-2xl bg-black/20"
|
||||
>
|
||||
<SpinnerIcon class="size-5 animate-spin text-white" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="pendingManualDownload"
|
||||
class="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-orange text-white"
|
||||
>
|
||||
<TriangleAlertIcon class="size-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<AutoLink
|
||||
:target="
|
||||
typeof projectLink === 'string' && projectLink.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="projectLink"
|
||||
class="truncate font-semibold leading-6 text-contrast !decoration-contrast"
|
||||
:class="{ 'hover:underline': projectLink }"
|
||||
>
|
||||
<MinecraftFormattedText :text="project.title" />
|
||||
</AutoLink>
|
||||
<TriangleAlertIcon
|
||||
v-if="postUpgradeWarningTooltip"
|
||||
v-tooltip="postUpgradeWarningTooltip"
|
||||
class="size-4 shrink-0 text-brand-orange"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<TriangleAlertIcon
|
||||
v-if="duplicateCount && duplicateCount > 1"
|
||||
v-tooltip="formatMessage(messages.duplicateMod, { count: duplicateCount })"
|
||||
class="size-4 shrink-0 text-red"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
v-if="dependencyBadge"
|
||||
v-tooltip="
|
||||
dependencyBadge.orphaned
|
||||
? formatMessage(messages.orphanedDependencyBadge)
|
||||
: formatMessage(messages.dependencyBadge)
|
||||
"
|
||||
class="shrink-0 rounded-md bg-surface-3 px-1.5 py-0.5 text-xs font-medium leading-4 text-secondary"
|
||||
>
|
||||
{{
|
||||
dependencyBadge.orphaned
|
||||
? formatMessage(messages.orphanedDependencyBadge)
|
||||
: formatMessage(messages.dependencyBadge)
|
||||
}}
|
||||
</span>
|
||||
<slot name="title-badges" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<span
|
||||
v-if="project.description"
|
||||
class="truncate text-sm leading-5 text-secondary"
|
||||
:title="project.description"
|
||||
>
|
||||
{{ project.description }}
|
||||
</span>
|
||||
<AutoLink
|
||||
v-if="owner && !project.description"
|
||||
:target="
|
||||
typeof owner.link === 'string' && owner.link.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="owner.link"
|
||||
class="flex shrink-0 items-center gap-1 !decoration-secondary"
|
||||
:class="{ 'hover:underline': owner.link }"
|
||||
>
|
||||
<Avatar
|
||||
:src="owner.avatar_url"
|
||||
:alt="owner.name"
|
||||
size="1.5rem"
|
||||
:circle="owner.type === 'user'"
|
||||
no-shadow
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="text-sm leading-5 text-secondary">{{ owner.name }}</span>
|
||||
</AutoLink>
|
||||
<template v-if="version && !project.description">
|
||||
<BulletDivider class="shrink-0 @[800px]:hidden" />
|
||||
<AutoLink
|
||||
:target="
|
||||
typeof versionLink === 'string' && versionLink.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="versionLink"
|
||||
class="truncate text-sm leading-5 text-secondary !decoration-secondary @[800px]:hidden"
|
||||
:class="{ 'hover:underline': versionLink }"
|
||||
>
|
||||
{{ version.version_number }}
|
||||
</AutoLink>
|
||||
</template>
|
||||
<template v-else-if="version && project.description">
|
||||
<BulletDivider class="shrink-0 @[800px]:hidden" />
|
||||
<AutoLink
|
||||
:target="
|
||||
typeof versionLink === 'string' && versionLink.startsWith('http')
|
||||
? '_blank'
|
||||
: undefined
|
||||
"
|
||||
:to="versionLink"
|
||||
class="truncate text-sm leading-5 text-secondary !decoration-secondary @[800px]:hidden"
|
||||
:class="{ 'hover:underline': versionLink }"
|
||||
>
|
||||
{{ version.version_number }}
|
||||
</AutoLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden flex-col gap-0.5 transition-[filter,opacity] duration-200 @[800px]:flex"
|
||||
:class="[
|
||||
hideActions ? 'flex-1' : 'flex-1 min-w-0',
|
||||
enabled === false && !disabled ? 'grayscale opacity-50' : '',
|
||||
]"
|
||||
>
|
||||
<template v-if="version">
|
||||
<AutoLink
|
||||
v-tooltip="truncatedTooltip(versionNumberRef, version.version_number)"
|
||||
:target="
|
||||
typeof versionLink === 'string' && versionLink.startsWith('http') ? '_blank' : undefined
|
||||
"
|
||||
:to="versionLink"
|
||||
class="inline-flex self-start font-medium leading-6 text-contrast !decoration-contrast"
|
||||
:class="{ 'hover:underline': versionLink, 'cursor-pointer': versionLink }"
|
||||
>
|
||||
<span ref="versionNumberRef" class="truncate">{{
|
||||
version.version_number.slice(0, Math.ceil(version.version_number.length / 2))
|
||||
}}</span
|
||||
><span class="shrink-0">{{
|
||||
version.version_number.slice(Math.ceil(version.version_number.length / 2))
|
||||
}}</span>
|
||||
</AutoLink>
|
||||
<span
|
||||
v-tooltip="truncatedTooltip(fileNameRef, version.file_name)"
|
||||
class="flex min-w-0 leading-6 text-secondary"
|
||||
>
|
||||
<span ref="fileNameRef" class="truncate">{{
|
||||
version.file_name.slice(0, Math.ceil(version.file_name.length / 2))
|
||||
}}</span
|
||||
><span class="shrink-0">{{
|
||||
version.file_name.slice(Math.ceil(version.file_name.length / 2))
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hideActions"
|
||||
class="flex min-w-[160px] shrink-0 items-center justify-end gap-2 transition-colors duration-200"
|
||||
>
|
||||
<slot name="additionalButtonsLeft" />
|
||||
|
||||
<ButtonStyled v-if="hasRollbackListener && rollbackFileName" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.rollbackTooltip, { fileName: rollbackFileName })"
|
||||
:aria-label="formatMessage(messages.rollbackTooltip, { fileName: rollbackFileName })"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('rollback')"
|
||||
>
|
||||
<UndoIcon class="size-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<!-- Fixed width container to reserve space for update/switch version button -->
|
||||
<div
|
||||
v-if="hasUpdateListener || hasSwitchVersionListener"
|
||||
class="flex w-8 items-center justify-center"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-if="hasUpdate"
|
||||
circular
|
||||
type="transparent"
|
||||
color="green"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
isDisabled && disabledTooltip
|
||||
? disabledTooltip
|
||||
: formatMessage(commonMessages.updateAvailableLabel)
|
||||
"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('update')"
|
||||
>
|
||||
<DownloadIcon class="size-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="hasSwitchVersionListener && version && !hideSwitchVersion"
|
||||
circular
|
||||
type="transparent"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
isDisabled && disabledTooltip
|
||||
? disabledTooltip
|
||||
: formatMessage(commonMessages.switchVersionButton)
|
||||
"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('switchVersion')"
|
||||
>
|
||||
<ArrowLeftRightIcon class="size-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<template v-for="action in inlineActions" :key="action.id">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="action.label"
|
||||
:aria-label="action.label"
|
||||
:disabled="isDisabled"
|
||||
@click="action.action"
|
||||
>
|
||||
<component :is="action.icon" class="size-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<Toggle
|
||||
v-if="enabled !== undefined"
|
||||
v-tooltip="
|
||||
isToggleDisabled && (toggleDisabledTooltip || disabledTooltip)
|
||||
? (toggleDisabledTooltip ?? disabledTooltip)
|
||||
: undefined
|
||||
"
|
||||
:model-value="enabled"
|
||||
:disabled="isToggleDisabled"
|
||||
:aria-label="plainProjectTitle"
|
||||
class="my-auto"
|
||||
@update:model-value="(val) => emit('update:enabled', val as boolean)"
|
||||
/>
|
||||
|
||||
<ButtonStyled v-if="hasDeleteListener && !props.hideDelete" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
isDisabled && disabledTooltip
|
||||
? disabledTooltip
|
||||
: formatMessage(
|
||||
shiftHeld && deleteHovered
|
||||
? commonMessages.deleteImmediatelyLabel
|
||||
: commonMessages.deleteLabel,
|
||||
)
|
||||
"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('delete', $event)"
|
||||
@mouseenter="deleteHovered = true"
|
||||
@mouseleave="deleteHovered = false"
|
||||
>
|
||||
<span class="relative size-5">
|
||||
<TrashIcon
|
||||
class="absolute inset-0 size-5 text-secondary transition-opacity duration-200"
|
||||
:class="shiftHeld && deleteHovered ? 'opacity-0' : 'opacity-100'"
|
||||
/>
|
||||
<TrashExclamationIcon
|
||||
class="absolute inset-0 size-5 text-red transition-opacity duration-200"
|
||||
:class="shiftHeld && deleteHovered ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<slot name="additionalButtonsRight" />
|
||||
|
||||
<ButtonStyled circular type="transparent">
|
||||
<TeleportOverflowMenu
|
||||
v-if="overflowOptions?.length"
|
||||
:options="overflowOptions"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
<MoreVerticalIcon class="size-5" />
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,414 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { computed, getCurrentInstance, ref, toRef, watch } from 'vue'
|
||||
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { useStickyObserver } from '#ui/composables/sticky-observer'
|
||||
import { useVirtualScroll } from '#ui/composables/virtual-scroll'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { useGroupSelection } from '../composables/group-selection'
|
||||
import type {
|
||||
ContentCardTableItem,
|
||||
ContentCardTableSortColumn,
|
||||
ContentCardTableSortDirection,
|
||||
} from '../types'
|
||||
import ContentCardItem from './ContentCardItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
interface Props {
|
||||
items: ContentCardTableItem[]
|
||||
showSelection?: boolean
|
||||
sortable?: boolean
|
||||
sortBy?: ContentCardTableSortColumn
|
||||
sortDirection?: ContentCardTableSortDirection
|
||||
virtualized?: boolean
|
||||
hideDelete?: boolean
|
||||
hideHeader?: boolean
|
||||
flat?: boolean
|
||||
expandedGroups?: Set<string>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showSelection: false,
|
||||
sortable: false,
|
||||
sortBy: undefined,
|
||||
sortDirection: 'asc',
|
||||
virtualized: true,
|
||||
hideDelete: false,
|
||||
hideHeader: false,
|
||||
flat: false,
|
||||
expandedGroups: () => new Set(),
|
||||
})
|
||||
|
||||
const stickyHeaderRef = ref<HTMLElement | null>(null)
|
||||
const { isStuck } = useStickyObserver(stickyHeaderRef, 'ContentCardTable')
|
||||
|
||||
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [id: string, value: boolean]
|
||||
delete: [id: string, event: MouseEvent]
|
||||
update: [id: string]
|
||||
switchVersion: [id: string]
|
||||
rollback: [id: string]
|
||||
sort: [column: ContentCardTableSortColumn, direction: ContentCardTableSortDirection]
|
||||
toggleExpand: [groupId: string]
|
||||
visibleItems: [items: ContentCardTableItem[]]
|
||||
}>()
|
||||
|
||||
// Check if any actions are available
|
||||
const instance = getCurrentInstance()
|
||||
const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function')
|
||||
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
|
||||
const hasSwitchVersionListener = computed(
|
||||
() => typeof instance?.vnode.props?.onSwitchVersion === 'function',
|
||||
)
|
||||
const hasEnabledListener = computed(
|
||||
() => typeof instance?.vnode.props?.['onUpdate:enabled'] === 'function',
|
||||
)
|
||||
|
||||
const hasAnyActions = computed(() => {
|
||||
// Check if there are listeners for actions
|
||||
const hasListeners =
|
||||
(hasDeleteListener.value && !props.hideDelete) ||
|
||||
hasUpdateListener.value ||
|
||||
hasSwitchVersionListener.value ||
|
||||
hasEnabledListener.value
|
||||
|
||||
// Check if any items have overflow options or updates
|
||||
const hasItemActions = props.items.some(
|
||||
(item) =>
|
||||
(item.overflowOptions && item.overflowOptions.length > 0) ||
|
||||
(item.inlineActions && item.inlineActions.length > 0) ||
|
||||
item.hasUpdate ||
|
||||
item.enabled !== undefined,
|
||||
)
|
||||
|
||||
return hasListeners || hasItemActions
|
||||
})
|
||||
|
||||
// Virtualization
|
||||
const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
|
||||
toRef(props, 'items'),
|
||||
{
|
||||
itemHeight: 74,
|
||||
bufferSize: 5,
|
||||
initialItemCount: 20,
|
||||
enabled: toRef(props, 'virtualized'),
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
visibleItems,
|
||||
(items) => {
|
||||
emit('visibleItems', items)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Expose for perf monitoring
|
||||
defineExpose({
|
||||
visibleRange,
|
||||
visibleItems,
|
||||
})
|
||||
|
||||
// Selection logic
|
||||
const {
|
||||
allSelected,
|
||||
someSelected,
|
||||
getGroupCheckboxState,
|
||||
isItemSelected,
|
||||
toggleSelectAll,
|
||||
toggleItemSelection,
|
||||
} = useGroupSelection({
|
||||
items: toRef(props, 'items'),
|
||||
selectedIds,
|
||||
})
|
||||
|
||||
const lastSelectedIndex = ref<number | null>(null)
|
||||
|
||||
function handleSort(column: ContentCardTableSortColumn) {
|
||||
if (!props.sortable) return
|
||||
|
||||
const newDirection: ContentCardTableSortDirection =
|
||||
props.sortBy === column && props.sortDirection === 'asc' ? 'desc' : 'asc'
|
||||
|
||||
emit('sort', column, newDirection)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-content-card-table
|
||||
role="table"
|
||||
class="@container border border-solid border-surface-4 shadow-sm overflow-clip"
|
||||
:class="[flat ? '' : 'rounded-[20px]', isStuck || hideHeader ? 'border-t-0' : '']"
|
||||
>
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
ref="stickyHeaderRef"
|
||||
role="rowgroup"
|
||||
class="sticky top-0 z-10 flex h-12 items-center justify-between gap-4 bg-surface-3 px-3"
|
||||
:class="[
|
||||
flat || isStuck ? 'rounded-none' : 'rounded-t-[20px]',
|
||||
isStuck
|
||||
? 'transition-[border-radius] duration-100 border-0 border-y border-solid border-surface-4 shadow-md before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-5 before:bg-surface-3'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
role="row"
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="hasAnyActions ? 'flex-1 min-w-0' : 'flex-1'"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="showSelection"
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
:aria-label="formatMessage(commonMessages.selectAllLabel)"
|
||||
class="shrink-0"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
|
||||
<template v-if="$slots['header-project']">
|
||||
<slot name="header-project" />
|
||||
</template>
|
||||
<button
|
||||
v-else-if="sortable"
|
||||
role="columnheader"
|
||||
:aria-sort="
|
||||
sortBy === 'project' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'
|
||||
"
|
||||
class="flex items-center gap-1.5 font-semibold text-secondary"
|
||||
@click="handleSort('project')"
|
||||
>
|
||||
{{ formatMessage(commonMessages.projectLabel) }}
|
||||
<ChevronUpIcon v-if="sortBy === 'project' && sortDirection === 'asc'" class="size-4" />
|
||||
<ChevronDownIcon
|
||||
v-else-if="sortBy === 'project' && sortDirection === 'desc'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
<span v-else role="columnheader" class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.projectLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!$slots['header-project']" class="hidden @[800px]:flex flex-none">
|
||||
<button
|
||||
v-if="sortable"
|
||||
role="columnheader"
|
||||
:aria-sort="
|
||||
sortBy === 'version' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'
|
||||
"
|
||||
class="flex items-center gap-1.5 font-semibold text-secondary"
|
||||
@click="handleSort('version')"
|
||||
>
|
||||
<ChevronUpIcon v-if="sortBy === 'version' && sortDirection === 'asc'" class="size-4" />
|
||||
<ChevronDownIcon
|
||||
v-else-if="sortBy === 'version' && sortDirection === 'desc'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
<span v-else role="columnheader" class="font-semibold text-secondary"></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasAnyActions || $slots['header-actions']"
|
||||
role="columnheader"
|
||||
:class="$slots['header-project'] ? 'shrink-0' : 'min-w-[160px] shrink-0'"
|
||||
>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="items.length > 0 && virtualized"
|
||||
ref="listContainer"
|
||||
role="rowgroup"
|
||||
class="relative w-full"
|
||||
:class="flat ? '' : 'rounded-b-[20px]'"
|
||||
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
|
||||
<ContentCardItem
|
||||
v-for="(item, idx) in visibleItems"
|
||||
:key="item.id"
|
||||
data-content-card-item
|
||||
:data-content-card-item-id="item.id"
|
||||
:project="item.project"
|
||||
:project-link="item.projectLink"
|
||||
:version="item.version"
|
||||
:version-link="item.versionLink"
|
||||
:owner="item.owner"
|
||||
:enabled="item.enabled"
|
||||
:installing="item.installing"
|
||||
:pending-manual-download="item.pendingManualDownload"
|
||||
:duplicate-count="item.duplicateCount"
|
||||
:has-update="item.hasUpdate"
|
||||
:rollback-file-name="item.rollbackFileName"
|
||||
:is-client-only="item.isClientOnly"
|
||||
:client-warning="item.clientWarning"
|
||||
:hide-switch-version="item.hideSwitchVersion"
|
||||
:overflow-options="item.overflowOptions"
|
||||
:disabled="item.disabled"
|
||||
:disabled-tooltip="item.disabledTooltip"
|
||||
:post-upgrade-warning-tooltip="item.postUpgradeWarningTooltip"
|
||||
:toggle-disabled="item.toggleDisabled"
|
||||
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
|
||||
:dependency-badge="item.dependencyBadge"
|
||||
:show-checkbox="showSelection"
|
||||
:hide-delete="hideDelete"
|
||||
:hide-actions="!hasAnyActions"
|
||||
:is-group-header="item.isGroupHeader"
|
||||
:group-depth="item.groupDepth"
|
||||
:group-item-count="item.groupItemCount"
|
||||
:is-group-child="!!item.group && !item.isGroupHeader"
|
||||
:group-kind="item.groupKind"
|
||||
:group-meta="item.groupMeta"
|
||||
:downloads="item.downloads"
|
||||
:categories="item.categories"
|
||||
:inline-actions="item.inlineActions"
|
||||
:group-checkbox-indeterminate="
|
||||
item.isGroupHeader ? getGroupCheckboxState(item).indeterminate : false
|
||||
"
|
||||
:group-expanded="
|
||||
item.isGroupHeader && item.group ? props.expandedGroups.has(item.group) : false
|
||||
"
|
||||
:group-switch-version="item.groupSwitchVersion"
|
||||
:selected="
|
||||
item.isGroupHeader ? getGroupCheckboxState(item).checked : isItemSelected(item.id)
|
||||
"
|
||||
:class="[
|
||||
isItemSelected(item.id)
|
||||
? 'bg-surface-2.5'
|
||||
: (visibleRange.start + idx) % 2 === 1
|
||||
? 'bg-surface-1.5'
|
||||
: 'bg-surface-2',
|
||||
'border-0 border-t border-solid border-surface-4',
|
||||
visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
|
||||
]"
|
||||
@select="
|
||||
(val, event) =>
|
||||
toggleItemSelection(
|
||||
item.id,
|
||||
val ?? false,
|
||||
lastSelectedIndex,
|
||||
visibleRange.start + idx,
|
||||
event,
|
||||
item,
|
||||
)
|
||||
"
|
||||
@update:enabled="(val) => emit('update:enabled', item.id, val)"
|
||||
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
|
||||
@update="emit('update', item.id)"
|
||||
@switch-version="emit('switchVersion', item.id)"
|
||||
@rollback="emit('rollback', item.id)"
|
||||
@toggle-expand="item.group ? emit('toggleExpand', item.group) : undefined"
|
||||
>
|
||||
<template #additionalButtonsLeft>
|
||||
<slot name="itemButtonsLeft" :item="item" :index="visibleRange.start + idx" />
|
||||
</template>
|
||||
<template #additionalButtonsRight>
|
||||
<slot name="itemButtonsRight" :item="item" :index="visibleRange.start + idx" />
|
||||
</template>
|
||||
</ContentCardItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="items.length > 0"
|
||||
ref="listContainer"
|
||||
role="rowgroup"
|
||||
:class="flat ? '' : 'rounded-b-[20px]'"
|
||||
>
|
||||
<ContentCardItem
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
data-content-card-item
|
||||
:data-content-card-item-id="item.id"
|
||||
:project="item.project"
|
||||
:project-link="item.projectLink"
|
||||
:version="item.version"
|
||||
:version-link="item.versionLink"
|
||||
:owner="item.owner"
|
||||
:enabled="item.enabled"
|
||||
:installing="item.installing"
|
||||
:pending-manual-download="item.pendingManualDownload"
|
||||
:duplicate-count="item.duplicateCount"
|
||||
:has-update="item.hasUpdate"
|
||||
:rollback-file-name="item.rollbackFileName"
|
||||
:is-client-only="item.isClientOnly"
|
||||
:client-warning="item.clientWarning"
|
||||
:overflow-options="item.overflowOptions"
|
||||
:disabled="item.disabled"
|
||||
:disabled-tooltip="item.disabledTooltip"
|
||||
:post-upgrade-warning-tooltip="item.postUpgradeWarningTooltip"
|
||||
:toggle-disabled="item.toggleDisabled"
|
||||
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
|
||||
:dependency-badge="item.dependencyBadge"
|
||||
:show-checkbox="showSelection"
|
||||
:hide-delete="hideDelete"
|
||||
:hide-actions="!hasAnyActions"
|
||||
:is-group-header="item.isGroupHeader"
|
||||
:group-depth="item.groupDepth"
|
||||
:group-item-count="item.groupItemCount"
|
||||
:is-group-child="!!item.group && !item.isGroupHeader"
|
||||
:group-kind="item.groupKind"
|
||||
:group-meta="item.groupMeta"
|
||||
:downloads="item.downloads"
|
||||
:categories="item.categories"
|
||||
:inline-actions="item.inlineActions"
|
||||
:group-checkbox-indeterminate="
|
||||
item.isGroupHeader ? getGroupCheckboxState(item).indeterminate : false
|
||||
"
|
||||
:group-expanded="
|
||||
item.isGroupHeader && item.group ? props.expandedGroups.has(item.group) : false
|
||||
"
|
||||
:group-switch-version="item.groupSwitchVersion"
|
||||
:selected="
|
||||
item.isGroupHeader ? getGroupCheckboxState(item).checked : isItemSelected(item.id)
|
||||
"
|
||||
:class="[
|
||||
isItemSelected(item.id)
|
||||
? 'bg-surface-2.5'
|
||||
: index % 2 === 1
|
||||
? 'bg-surface-1.5'
|
||||
: 'bg-surface-2',
|
||||
'border-0 border-t border-solid border-surface-4',
|
||||
index === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
|
||||
]"
|
||||
@select="
|
||||
(val, event) =>
|
||||
toggleItemSelection(item.id, val ?? false, lastSelectedIndex, index, event, item)
|
||||
"
|
||||
@update:enabled="(val) => emit('update:enabled', item.id, val)"
|
||||
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
|
||||
@update="emit('update', item.id)"
|
||||
@switch-version="emit('switchVersion', item.id)"
|
||||
@rollback="emit('rollback', item.id)"
|
||||
@toggle-expand="item.group ? emit('toggleExpand', item.group) : undefined"
|
||||
>
|
||||
<template #additionalButtonsLeft>
|
||||
<slot name="itemButtonsLeft" :item="item" :index="index" />
|
||||
</template>
|
||||
<template #additionalButtonsRight>
|
||||
<slot name="itemButtonsRight" :item="item" :index="index" />
|
||||
</template>
|
||||
</ContentCardItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center py-12"
|
||||
:class="flat ? '' : 'rounded-b-[20px]'"
|
||||
>
|
||||
<slot name="empty">
|
||||
<span class="text-secondary">{{ formatMessage(commonMessages.noItemsLabel) }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { CompassIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
noContentInstalled: {
|
||||
id: 'content.page-layout.empty.no-content-installed',
|
||||
defaultMessage: 'No content installed',
|
||||
},
|
||||
emptyHint: {
|
||||
id: 'content.page-layout.empty.hint',
|
||||
defaultMessage: 'Browse or upload {contentType} to get started',
|
||||
},
|
||||
browseContent: {
|
||||
id: 'content.page-layout.browse-content',
|
||||
defaultMessage: 'Browse content',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
contentTypeLabel: string
|
||||
busy?: boolean
|
||||
busyTooltip?: string | null
|
||||
refreshing?: boolean
|
||||
disableAddContent?: boolean
|
||||
disableAddContentTooltip?: string
|
||||
}>(),
|
||||
{
|
||||
busy: false,
|
||||
busyTooltip: null,
|
||||
refreshing: false,
|
||||
disableAddContent: false,
|
||||
disableAddContentTooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: []
|
||||
refresh: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyState type="empty-inbox">
|
||||
<template #heading>
|
||||
{{ formatMessage(messages.noContentInstalled) }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{
|
||||
formatMessage(messages.emptyHint, {
|
||||
contentType: formatContentTypeSentence(
|
||||
formatMessage,
|
||||
props.contentTypeLabel,
|
||||
2,
|
||||
'content',
|
||||
),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
v-tooltip="props.busyTooltip"
|
||||
:disabled="props.refreshing"
|
||||
class="!h-10"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCwIcon :class="['size-5', { 'animate-spin': props.refreshing }]" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
props.busyTooltip ??
|
||||
(props.disableAddContent ? props.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="props.busy || props.disableAddContent"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="emit('browse')"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</template>
|
||||
@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon } from '@modrinth/assets'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import MultiSelect from '#ui/components/base/MultiSelect.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import { type MetadataFilterCategory, useHorizontalFilterScroll } from '../composables'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'content.metadata-filter.search',
|
||||
defaultMessage: 'Search...',
|
||||
},
|
||||
clear: {
|
||||
id: 'content.metadata-filter.clear',
|
||||
defaultMessage: 'Clear',
|
||||
},
|
||||
selectAll: {
|
||||
id: 'content.metadata-filter.select-all',
|
||||
defaultMessage: 'Select all',
|
||||
},
|
||||
filterToggle: {
|
||||
id: 'content.metadata-filter.toggle',
|
||||
defaultMessage: 'Filter',
|
||||
},
|
||||
filterToggleActive: {
|
||||
id: 'content.metadata-filter.toggle-active',
|
||||
defaultMessage: 'Filter ({count, number} active)',
|
||||
},
|
||||
longPressReset: {
|
||||
id: 'content.metadata-filter.long-press-reset',
|
||||
defaultMessage: 'Long-press to reset filters',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: MetadataFilterCategory[]
|
||||
modelValue: Record<string, string[]>
|
||||
filteringKeys?: string[]
|
||||
activeFilterCount?: number
|
||||
}>(),
|
||||
{
|
||||
filteringKeys: () => [],
|
||||
activeFilterCount: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:category': [key: string, values: string[]]
|
||||
}>()
|
||||
|
||||
const expanded = defineModel<boolean>('expanded', { default: false })
|
||||
|
||||
// ---- 长按重置筛选(仅在展开状态生效) ----
|
||||
|
||||
const LONG_PRESS_MS = 600
|
||||
const RING_DELAY_MS = 120
|
||||
const RING_FILL_MS = LONG_PRESS_MS - RING_DELAY_MS
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let ringTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let longPressTriggered = false
|
||||
const pressing = ref(false)
|
||||
|
||||
function resetAllFilters() {
|
||||
for (const category of props.categories) {
|
||||
emit(
|
||||
'update:category',
|
||||
category.key,
|
||||
category.options.map((option) => option.value),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function startLongPress() {
|
||||
if (!expanded.value) return
|
||||
longPressTriggered = false
|
||||
ringTimer = setTimeout(() => {
|
||||
ringTimer = null
|
||||
pressing.value = true
|
||||
}, RING_DELAY_MS)
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null
|
||||
pressing.value = false
|
||||
longPressTriggered = true
|
||||
resetAllFilters()
|
||||
}, LONG_PRESS_MS)
|
||||
}
|
||||
|
||||
function cancelLongPress() {
|
||||
if (ringTimer !== null) {
|
||||
clearTimeout(ringTimer)
|
||||
ringTimer = null
|
||||
}
|
||||
if (longPressTimer !== null) {
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
pressing.value = false
|
||||
}
|
||||
|
||||
function handleToggleClick() {
|
||||
if (longPressTriggered) {
|
||||
longPressTriggered = false
|
||||
return
|
||||
}
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (longPressTimer !== null) clearTimeout(longPressTimer)
|
||||
if (ringTimer !== null) clearTimeout(ringTimer)
|
||||
})
|
||||
|
||||
const filterScrollRef = ref<HTMLElement | null>(null)
|
||||
const scrollbarThumbRef = ref<HTMLElement | null>(null)
|
||||
const { suppressHoverOpen, handleScroll } = useHorizontalFilterScroll(
|
||||
filterScrollRef,
|
||||
scrollbarThumbRef,
|
||||
)
|
||||
|
||||
function selectedCount(category: MetadataFilterCategory): number {
|
||||
return (props.modelValue[category.key] ?? []).length
|
||||
}
|
||||
|
||||
function isCategoryFiltering(category: MetadataFilterCategory): boolean {
|
||||
return props.filteringKeys.includes(category.key)
|
||||
}
|
||||
|
||||
function filterButtonLabel(): string {
|
||||
if (expanded.value) return formatMessage(messages.longPressReset)
|
||||
if (props.activeFilterCount > 0) {
|
||||
return formatMessage(messages.filterToggleActive, { count: props.activeFilterCount })
|
||||
}
|
||||
return formatMessage(messages.filterToggle)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group relative flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Tooltip
|
||||
:delay="{ show: 0, hide: 0 }"
|
||||
popper-class="filter-metadata-tooltip"
|
||||
placement="bottom"
|
||||
:distance="6"
|
||||
>
|
||||
<ButtonStyled
|
||||
circular
|
||||
:type="expanded || props.activeFilterCount > 0 ? 'chip' : 'transparent'"
|
||||
:color="expanded || props.activeFilterCount > 0 ? 'brand' : 'standard'"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button
|
||||
class="relative"
|
||||
:aria-label="filterButtonLabel()"
|
||||
:aria-expanded="expanded"
|
||||
@click="handleToggleClick"
|
||||
@pointerdown="startLongPress"
|
||||
@pointerup="cancelLongPress"
|
||||
@pointerleave="cancelLongPress"
|
||||
@pointercancel="cancelLongPress"
|
||||
>
|
||||
<FilterIcon />
|
||||
<span
|
||||
v-if="props.activeFilterCount > 0"
|
||||
aria-hidden="true"
|
||||
class="absolute -right-2 -top-2 min-w-4 rounded-full bg-brand-highlight px-1 text-[0.625rem] font-semibold leading-4 text-brand"
|
||||
>
|
||||
{{ props.activeFilterCount }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template #popper>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="whitespace-nowrap text-xs font-semibold">
|
||||
{{ filterButtonLabel() }}
|
||||
</span>
|
||||
<div
|
||||
v-if="pressing"
|
||||
class="long-press-bar h-1 w-full min-w-[5rem] overflow-hidden rounded-full bg-surface-5"
|
||||
>
|
||||
<div
|
||||
class="long-press-bar-fill h-full rounded-full bg-brand"
|
||||
:style="{ animationDuration: RING_FILL_MS + 'ms' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
class="grid min-w-0 flex-1 transition-[grid-template-columns] duration-300 ease-in-out"
|
||||
:class="expanded ? 'grid-cols-[1fr]' : 'grid-cols-[0fr]'"
|
||||
>
|
||||
<div class="relative min-w-0 overflow-hidden">
|
||||
<div
|
||||
ref="filterScrollRef"
|
||||
class="content-filter-scroll flex w-full min-w-0 flex-nowrap items-center gap-1.5 px-1.5"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<MultiSelect
|
||||
v-for="category in props.categories"
|
||||
:key="category.key"
|
||||
:model-value="props.modelValue[category.key] ?? []"
|
||||
:options="category.options"
|
||||
:max-height="420"
|
||||
:clearable="false"
|
||||
:show-chevron="false"
|
||||
:fit-content="true"
|
||||
:searchable="category.searchable"
|
||||
:search-placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
:trigger-class="'h-8 shrink-0 !rounded-full border-0 px-2.5 transition-all hover:brightness-110 active:brightness-110'"
|
||||
:active="selectedCount(category) === category.options.length"
|
||||
:dropdown-min-width="'15rem'"
|
||||
:checkbox-position="'left'"
|
||||
:hover-open="!suppressHoverOpen"
|
||||
show-selection-actions
|
||||
:selection-actions-clear-label="formatMessage(messages.clear)"
|
||||
:selection-actions-select-all-label="formatMessage(messages.selectAll)"
|
||||
@update:model-value="(values) => emit('update:category', category.key, values)"
|
||||
>
|
||||
<template #input-content>
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold">
|
||||
<span class="truncate">{{ category.label }}</span>
|
||||
<span
|
||||
v-if="isCategoryFiltering(category)"
|
||||
class="rounded-full bg-brand-highlight px-1.5 text-xs font-normal tabular-nums text-brand"
|
||||
>
|
||||
{{ selectedCount(category) }}/{{ category.options.length }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="scrollbarThumbRef"
|
||||
class="pointer-events-none absolute bottom-0 left-0 z-10 h-[3px] rounded-full bg-surface-5 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 隐藏原生滚动条(不占布局空间),滚动条由自绘悬浮条替代 */
|
||||
.content-filter-scroll {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.content-filter-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.long-press-bar-fill {
|
||||
animation: long-press-bar-fill 480ms linear forwards;
|
||||
}
|
||||
|
||||
@keyframes long-press-bar-fill {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.filter-metadata-tooltip {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { PowerIcon, PowerOffIcon, XIcon } from '@modrinth/assets'
|
||||
import { autoCleanToText, autoToHTML } from '@sfirew/minecraft-motd-parser'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.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 { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
|
||||
|
||||
import type { BulkOperationType } from '../composables/bulk-operations'
|
||||
import {
|
||||
canToggleContentItem,
|
||||
isDisabledContentItem,
|
||||
isEnabledContentItem,
|
||||
} from '../composables/content-filtering'
|
||||
import type { ContentItem } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
selectedCount: {
|
||||
id: 'content.selection-bar.selected-count',
|
||||
defaultMessage: '{count, number} {contentType} selected',
|
||||
},
|
||||
selectedCountSimple: {
|
||||
id: 'content.selection-bar.selected-count-simple',
|
||||
defaultMessage: '{count, number} selected',
|
||||
},
|
||||
bulkEnabling: {
|
||||
id: 'content.selection-bar.bulk.enabling',
|
||||
defaultMessage: 'Enabling {progress}/{total} {contentType}...',
|
||||
},
|
||||
bulkEnablingWaiting: {
|
||||
id: 'content.selection-bar.bulk.enabling-waiting',
|
||||
defaultMessage: 'Enabling {contentType}...',
|
||||
},
|
||||
bulkDisabling: {
|
||||
id: 'content.selection-bar.bulk.disabling',
|
||||
defaultMessage: 'Disabling {progress}/{total} {contentType}...',
|
||||
},
|
||||
bulkDisablingWaiting: {
|
||||
id: 'content.selection-bar.bulk.disabling-waiting',
|
||||
defaultMessage: 'Disabling {contentType}...',
|
||||
},
|
||||
bulkUpdating: {
|
||||
id: 'content.selection-bar.bulk.updating',
|
||||
defaultMessage: 'Updating {progress}/{total} {contentType}...',
|
||||
},
|
||||
bulkUpdatingWaiting: {
|
||||
id: 'content.selection-bar.bulk.updating-waiting',
|
||||
defaultMessage: 'Updating {contentType}...',
|
||||
},
|
||||
bulkUpdatingCount: {
|
||||
id: 'content.selection-bar.bulk.updating-count',
|
||||
defaultMessage: 'Updating {count, number} {contentType}',
|
||||
},
|
||||
bulkDeleting: {
|
||||
id: 'content.selection-bar.bulk.deleting',
|
||||
defaultMessage: 'Deleting {progress}/{total} {contentType}...',
|
||||
},
|
||||
bulkDeletingWaiting: {
|
||||
id: 'content.selection-bar.bulk.deleting-waiting',
|
||||
defaultMessage: 'Deleting {contentType}...',
|
||||
},
|
||||
bulkEnablingCount: {
|
||||
id: 'content.selection-bar.bulk.enabling-count',
|
||||
defaultMessage: 'Enabling {count, number} {contentType}',
|
||||
},
|
||||
bulkDisablingCount: {
|
||||
id: 'content.selection-bar.bulk.disabling-count',
|
||||
defaultMessage: 'Disabling {count, number} {contentType}',
|
||||
},
|
||||
bulkDeletingCount: {
|
||||
id: 'content.selection-bar.bulk.deleting-count',
|
||||
defaultMessage: 'Deleting {count, number} {contentType}',
|
||||
},
|
||||
allAlreadyEnabled: {
|
||||
id: 'content.selection-bar.all-already-enabled',
|
||||
defaultMessage: 'All selected content is already enabled',
|
||||
},
|
||||
allAlreadyDisabled: {
|
||||
id: 'content.selection-bar.all-already-disabled',
|
||||
defaultMessage: 'All selected content is already disabled',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
selectedItems: ContentItem[]
|
||||
contentTypeLabel?: string
|
||||
isBusy?: boolean
|
||||
busyTooltip?: string | null
|
||||
isBulkOperating?: boolean
|
||||
bulkOperation?: BulkOperationType | null
|
||||
bulkProgress?: number
|
||||
bulkTotal?: number
|
||||
bulkWaiting?: boolean
|
||||
bulkStatusMessage?: string | null
|
||||
bulkItemCount?: number
|
||||
ariaLabel?: string
|
||||
getItemId?: (item: ContentItem) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentTypeLabel: undefined,
|
||||
isBusy: false,
|
||||
busyTooltip: undefined,
|
||||
isBulkOperating: false,
|
||||
bulkOperation: null,
|
||||
bulkProgress: 0,
|
||||
bulkTotal: 0,
|
||||
bulkWaiting: false,
|
||||
bulkStatusMessage: null,
|
||||
bulkItemCount: 0,
|
||||
ariaLabel: undefined,
|
||||
getItemId: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
enable: []
|
||||
disable: []
|
||||
}>()
|
||||
|
||||
const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating)
|
||||
const iconStackOffset = 24
|
||||
const visibleItems = computed(() => props.selectedItems.slice(0, 3))
|
||||
const overflowCount = computed(() => Math.max(0, props.selectedItems.length - 3))
|
||||
const iconStackWidth = computed(() => {
|
||||
if (props.selectedItems.length === 0) return 0
|
||||
return 32 + (visibleItems.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
|
||||
})
|
||||
|
||||
function resolveItemId(item: ContentItem) {
|
||||
return props.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
|
||||
}
|
||||
|
||||
function resolveItemTitle(item: ContentItem) {
|
||||
return item.project?.title ?? item.file_name
|
||||
}
|
||||
|
||||
function itemTitleTooltip(item: ContentItem) {
|
||||
return { content: autoToHTML(resolveItemTitle(item)), html: true }
|
||||
}
|
||||
|
||||
const allDisabled = computed(
|
||||
() =>
|
||||
!props.selectedItems.some((item) => canToggleContentItem(item) && isEnabledContentItem(item)),
|
||||
)
|
||||
const allEnabled = computed(
|
||||
() =>
|
||||
!props.selectedItems.some((item) => canToggleContentItem(item) && isDisabledContentItem(item)),
|
||||
)
|
||||
|
||||
const selectedCountText = computed(() => {
|
||||
const count = props.isBulkOperating
|
||||
? props.bulkItemCount || props.bulkTotal || props.selectedItems.length
|
||||
: props.selectedItems.length || props.bulkTotal
|
||||
if (props.isBulkOperating && props.bulkOperation) {
|
||||
const messageMap = {
|
||||
enable: messages.bulkEnablingCount,
|
||||
disable: messages.bulkDisablingCount,
|
||||
update: messages.bulkUpdatingCount,
|
||||
delete: messages.bulkDeletingCount,
|
||||
}
|
||||
return formatMessage(messageMap[props.bulkOperation], {
|
||||
count,
|
||||
contentType: formatContentTypeSentence(formatMessage, props.contentTypeLabel, count),
|
||||
})
|
||||
}
|
||||
|
||||
if (props.contentTypeLabel) {
|
||||
return formatMessage(messages.selectedCount, {
|
||||
count,
|
||||
contentType: formatContentTypeSentence(formatMessage, props.contentTypeLabel, count),
|
||||
})
|
||||
}
|
||||
return formatMessage(messages.selectedCountSimple, { count })
|
||||
})
|
||||
|
||||
const bulkProgressMessage = computed(() => {
|
||||
if (props.bulkStatusMessage) return props.bulkStatusMessage
|
||||
if (!props.bulkOperation) return ''
|
||||
const messageMap = {
|
||||
enable: props.bulkWaiting ? messages.bulkEnablingWaiting : messages.bulkEnabling,
|
||||
disable: props.bulkWaiting ? messages.bulkDisablingWaiting : messages.bulkDisabling,
|
||||
update: props.bulkWaiting ? messages.bulkUpdatingWaiting : messages.bulkUpdating,
|
||||
delete: props.bulkWaiting ? messages.bulkDeletingWaiting : messages.bulkDeleting,
|
||||
}
|
||||
return formatMessage(messageMap[props.bulkOperation], {
|
||||
progress: props.bulkProgress,
|
||||
total: props.bulkTotal,
|
||||
contentType: formatContentTypeSentence(formatMessage, props.contentTypeLabel, props.bulkTotal),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingActionBar :shown="shown" :aria-label="ariaLabel" hide-when-modal-open>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div
|
||||
v-if="selectedItems.length > 0"
|
||||
class="relative h-8 shrink-0"
|
||||
:style="{ width: `${iconStackWidth}px` }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="resolveItemId(item)"
|
||||
v-tooltip="itemTitleTooltip(item)"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
|
||||
:style="{ left: `${index * iconStackOffset}px`, zIndex: visibleItems.length - index }"
|
||||
>
|
||||
<Avatar
|
||||
:src="item.project?.icon_url"
|
||||
:alt="autoCleanToText(resolveItemTitle(item))"
|
||||
:tint-by="resolveItemId(item)"
|
||||
size="100%"
|
||||
no-shadow
|
||||
class="selected-content-avatar"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="overflowCount > 0"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4 text-xs font-bold text-contrast"
|
||||
:style="{ left: `${visibleItems.length * iconStackOffset}px`, zIndex: 0 }"
|
||||
>
|
||||
+{{ overflowCount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="px-3 py-2 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ selectedCountText }}
|
||||
</span>
|
||||
<div class="mx-0.5 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.clearButton)"
|
||||
class="!text-primary"
|
||||
:disabled="isBulkOperating"
|
||||
:class="{ 'opacity-60 pointer-events-none': isBulkOperating }"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
<XIcon class="hidden cq-show-icon" />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
|
||||
<slot name="actions" />
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
isBusy && busyTooltip
|
||||
? busyTooltip
|
||||
: allEnabled
|
||||
? formatMessage(messages.allAlreadyEnabled)
|
||||
: formatMessage(commonMessages.enableButton)
|
||||
"
|
||||
:disabled="isBusy || allEnabled"
|
||||
@click="emit('enable')"
|
||||
>
|
||||
<PowerIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.enableButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
isBusy && busyTooltip
|
||||
? busyTooltip
|
||||
: allDisabled
|
||||
? formatMessage(messages.allAlreadyDisabled)
|
||||
: formatMessage(commonMessages.disableButton)
|
||||
"
|
||||
:disabled="isBusy || allDisabled"
|
||||
@click="emit('disable')"
|
||||
>
|
||||
<PowerOffIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.disableButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<slot name="actions-end" />
|
||||
</div>
|
||||
|
||||
<div v-else class="ml-auto flex items-center" aria-live="polite">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-secondary tabular-nums">
|
||||
{{ bulkProgressMessage }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isBulkOperating" class="absolute bottom-0 left-0 right-0 h-1">
|
||||
<div
|
||||
class="h-full rounded-l-full bg-brand transition-[width] duration-200 ease-in-out"
|
||||
:class="{ 'animate-indeterminate': bulkWaiting }"
|
||||
:style="
|
||||
!bulkWaiting
|
||||
? { width: `${bulkTotal > 0 ? (bulkProgress / bulkTotal) * 100 : 0}%` }
|
||||
: undefined
|
||||
"
|
||||
role="progressbar"
|
||||
:aria-valuenow="bulkWaiting ? undefined : bulkProgress"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="bulkTotal"
|
||||
style="box-shadow: 0px -2px 4px 0px rgba(27, 217, 106, 0.1)"
|
||||
/>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
width: 20%;
|
||||
margin-left: -20%;
|
||||
}
|
||||
100% {
|
||||
width: 60%;
|
||||
margin-left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-indeterminate {
|
||||
animation: indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:deep(.selected-content-avatar) {
|
||||
background-color: var(--color-button-bg);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownAZIcon,
|
||||
ArrowUpZAIcon,
|
||||
CheckIcon,
|
||||
ClockArrowDownIcon,
|
||||
ClockArrowUpIcon,
|
||||
DownloadIcon,
|
||||
PinIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
} from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import PopoutMenu from '#ui/components/base/PopoutMenu.vue'
|
||||
|
||||
import type { ContentSortMode } from '../composables'
|
||||
|
||||
export interface ContentSortOption {
|
||||
id: ContentSortMode
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sortMode: ContentSortMode
|
||||
sortLabel: string
|
||||
sortOptions: ContentSortOption[]
|
||||
viewOptionsLabel: string
|
||||
pinned: boolean
|
||||
pinTooltip: string
|
||||
resetTooltip: string
|
||||
hasBulkUpdateSupport?: boolean
|
||||
hasOutdatedProjects?: boolean
|
||||
bulkUpdateTooltip?: string
|
||||
isBulkOperating?: boolean
|
||||
}>(),
|
||||
{
|
||||
hasBulkUpdateSupport: false,
|
||||
hasOutdatedProjects: false,
|
||||
bulkUpdateTooltip: undefined,
|
||||
isBulkOperating: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectSort: [mode: ContentSortMode]
|
||||
togglePin: []
|
||||
resetView: []
|
||||
updateAll: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<PopoutMenu :tooltip="props.sortLabel" placement="bottom-end">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button :aria-label="props.sortLabel">
|
||||
<ArrowUpZAIcon
|
||||
v-if="props.sortMode === 'project-name-desc' || props.sortMode === 'file-name-desc'"
|
||||
/>
|
||||
<ClockArrowDownIcon v-else-if="props.sortMode === 'date-added-newest'" />
|
||||
<ClockArrowUpIcon v-else-if="props.sortMode === 'date-added-oldest'" />
|
||||
<ArrowDownAZIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #menu>
|
||||
<div class="flex w-56 flex-col gap-1 p-1" role="menu" :aria-label="props.viewOptionsLabel">
|
||||
<ButtonStyled
|
||||
v-for="option in props.sortOptions"
|
||||
:key="option.id"
|
||||
:type="props.sortMode === option.id ? 'filled' : 'transparent'"
|
||||
>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 !justify-start text-left"
|
||||
role="menuitemradio"
|
||||
:aria-checked="props.sortMode === option.id"
|
||||
@click="emit('selectSort', option.id)"
|
||||
>
|
||||
<CheckIcon
|
||||
class="size-4 shrink-0"
|
||||
:class="props.sortMode === option.id ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="my-1 h-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 !justify-start text-left"
|
||||
@click="emit('resetView')"
|
||||
>
|
||||
<RotateCounterClockwiseIcon class="size-4" />
|
||||
<span>{{ props.resetTooltip }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
|
||||
<ButtonStyled
|
||||
circular
|
||||
:type="props.pinned ? 'chip' : 'transparent'"
|
||||
:color="props.pinned ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="props.pinTooltip"
|
||||
:aria-label="props.pinTooltip"
|
||||
@click="emit('togglePin')"
|
||||
>
|
||||
<PinIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled
|
||||
v-if="props.hasBulkUpdateSupport && props.hasOutdatedProjects"
|
||||
circular
|
||||
color="green"
|
||||
type="transparent"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button
|
||||
v-tooltip="props.bulkUpdateTooltip"
|
||||
:disabled="props.isBulkOperating"
|
||||
@click="emit('updateAll')"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { CompassIcon, GitGraphIcon, RefreshCwIcon, SearchIcon } from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'content.page-layout.search-placeholder',
|
||||
defaultMessage: 'Search {count, number} {contentType}...',
|
||||
},
|
||||
browseContent: {
|
||||
id: 'content.page-layout.browse-content',
|
||||
defaultMessage: 'Browse content',
|
||||
},
|
||||
viewDependencies: {
|
||||
id: 'content.page-layout.view-dependencies',
|
||||
defaultMessage: 'View dependencies',
|
||||
},
|
||||
})
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
searchableItemCount: number
|
||||
contentTypeLabel: string
|
||||
busy?: boolean
|
||||
busyTooltip?: string | null
|
||||
disableAddContent?: boolean
|
||||
disableAddContentTooltip?: string
|
||||
refreshing?: boolean
|
||||
viewDependencies?: boolean
|
||||
}>(),
|
||||
{
|
||||
busy: false,
|
||||
busyTooltip: null,
|
||||
disableAddContent: false,
|
||||
disableAddContentTooltip: undefined,
|
||||
refreshing: false,
|
||||
viewDependencies: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: []
|
||||
refresh: []
|
||||
viewDependencies: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: props.searchableItemCount,
|
||||
contentType: formatContentTypeSentence(
|
||||
formatMessage,
|
||||
props.contentTypeLabel,
|
||||
props.searchableItemCount,
|
||||
),
|
||||
})
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
props.busyTooltip ??
|
||||
(props.disableAddContent ? props.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="props.busy || props.disableAddContent"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="emit('browse')"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="props.viewDependencies" type="outlined">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewDependencies)"
|
||||
:disabled="props.busy"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="emit('viewDependencies')"
|
||||
>
|
||||
<GitGraphIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.viewDependencies) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
v-tooltip="props.busyTooltip"
|
||||
:disabled="props.refreshing"
|
||||
class="!h-10"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCwIcon :class="['size-5', { 'animate-spin': props.refreshing }]" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { ContentFilterOption } from '../composables'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selected = defineModel<string[]>('selected', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
options: ContentFilterOption[]
|
||||
totalCount: number
|
||||
filterCounts: Record<string, number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [id: string, event: MouseEvent]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="@container flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
class="cursor-pointer rounded-full px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||
:class="
|
||||
selected.length === 0
|
||||
? 'bg-brand-highlight text-brand'
|
||||
: 'bg-surface-4 text-primary hover:bg-surface-5'
|
||||
"
|
||||
:aria-pressed="selected.length === 0"
|
||||
@click="selected = []"
|
||||
>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
<span class="ml-1 text-sm font-normal opacity-70">{{ props.totalCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="option in props.options"
|
||||
:key="option.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
||||
:class="
|
||||
selected.includes(option.id)
|
||||
? 'bg-brand-highlight text-brand'
|
||||
: 'bg-surface-4 text-primary hover:bg-surface-5'
|
||||
"
|
||||
:aria-pressed="selected.includes(option.id)"
|
||||
@click="(event) => emit('toggle', option.id, event)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="ml-1 text-sm font-normal opacity-70">{{
|
||||
props.filterCounts[option.id] ?? 0
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<SymlinkWarningAdmonition :symlink-target="symlinkTarget" />
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{
|
||||
props.scopeDescription ?? formatMessage(messages.admonitionBody, { count: visibleCount })
|
||||
}}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ props.actionLabel ?? formatMessage(messages.updateButton, { count: visibleCount }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import SymlinkWarningAdmonition from './SymlinkWarningAdmonition.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-bulk-update.header',
|
||||
defaultMessage: 'Update projects',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.confirm-bulk-update.admonition-header',
|
||||
defaultMessage: 'Update warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-bulk-update.admonition-body',
|
||||
defaultMessage:
|
||||
"Are you sure you want to update {count, plural, one {# project} other {# projects}} to their latest compatible version? It's recommended to update content one-by-one.",
|
||||
},
|
||||
updateButton: {
|
||||
id: 'content.confirm-bulk-update.update-button',
|
||||
defaultMessage: 'Update {count, plural, one {# project} other {# projects}}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
count: number
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
symlinkTarget?: string
|
||||
actionLabel?: string
|
||||
scopeDescription?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const visibleCount = ref(props.count)
|
||||
|
||||
async function show() {
|
||||
await nextTick()
|
||||
visibleCount.value = props.count
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('update')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
formatMessage(messages.header, {
|
||||
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
|
||||
})
|
||||
"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SymlinkWarningAdmonition :symlink-target="symlinkTarget" />
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{
|
||||
formatMessage(messages.deleteButton, {
|
||||
count: visibleCount,
|
||||
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
|
||||
|
||||
import SymlinkWarningAdmonition from './SymlinkWarningAdmonition.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-deletion.header',
|
||||
defaultMessage: 'Delete {itemType}',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.confirm-deletion.admonition-header',
|
||||
defaultMessage: 'Deletion warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-deletion.admonition-body',
|
||||
defaultMessage:
|
||||
'Deleting a mod can permanently affect your world and may cause missing content or unexpected issues when it loads again.',
|
||||
},
|
||||
deleteButton: {
|
||||
id: 'content.confirm-deletion.delete-button',
|
||||
defaultMessage: 'Delete {count, number} {itemType}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
count: number
|
||||
itemType: string
|
||||
variant?: 'instance' | 'server'
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
symlinkTarget?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'instance',
|
||||
backupTip: undefined,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
symlinkTarget: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const visibleCount = ref(props.count)
|
||||
const visibleItemType = ref(props.itemType)
|
||||
|
||||
async function show() {
|
||||
await nextTick()
|
||||
visibleCount.value = props.count
|
||||
visibleItemType.value = props.itemType
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { action: downgrade ? 'downgrade' : 'update' })"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SymlinkWarningAdmonition :symlink-target="symlinkTarget" />
|
||||
<Admonition
|
||||
type="warning"
|
||||
:header="
|
||||
formatMessage(messages.admonitionHeader, { action: downgrade ? 'downgrade' : 'update' })
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.admonitionBody, {
|
||||
action: downgrade ? 'downgrade' : 'update',
|
||||
})
|
||||
}}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="props.actionDisabled"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{
|
||||
formatMessage(messages.confirmButton, { action: downgrade ? 'downgrade' : 'update' })
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import SymlinkWarningAdmonition from './SymlinkWarningAdmonition.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
downgrade?: boolean
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
symlinkTarget?: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-modpack-update.header',
|
||||
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} modpack',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.confirm-modpack-update.admonition-header',
|
||||
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-modpack-update.admonition-body',
|
||||
defaultMessage:
|
||||
'{action, select, downgrade {Downgrading} other {Updating}} may cause compatibility issues. Mods or content you added on top of the modpack will be kept, but may not be compatible with the new version.',
|
||||
},
|
||||
confirmButton: {
|
||||
id: 'content.confirm-modpack-update.confirm-button',
|
||||
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} modpack',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm' | 'cancel'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<SymlinkWarningAdmonition :symlink-target="symlinkTarget" />
|
||||
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirm">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.reinstallButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import SymlinkWarningAdmonition from './SymlinkWarningAdmonition.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instance.confirm-reinstall.header',
|
||||
defaultMessage: 'Reinstall modpack',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'instance.confirm-reinstall.admonition-header',
|
||||
defaultMessage: 'Reinstallation warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'instance.confirm-reinstall.admonition-body',
|
||||
defaultMessage:
|
||||
'Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation.',
|
||||
},
|
||||
reinstallButton: {
|
||||
id: 'instance.confirm-reinstall.reinstall-button',
|
||||
defaultMessage: 'Reinstall modpack',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
symlinkTarget?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reinstall'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('reinstall')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
formatMessage(messages.header, {
|
||||
type: formatMessage(server ? messages.serverLabel : messages.instanceLabel),
|
||||
})
|
||||
"
|
||||
max-width="500px"
|
||||
>
|
||||
<SymlinkWarningAdmonition :symlink-target="symlinkTarget" />
|
||||
<span class="text-primary">
|
||||
{{ formatMessage(server ? messages.serverBody : messages.instanceBody) }}
|
||||
</span>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="confirm">
|
||||
<HammerIcon />
|
||||
{{ formatMessage(messages.repairButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HammerIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import SymlinkWarningAdmonition from './SymlinkWarningAdmonition.vue'
|
||||
|
||||
defineProps<{
|
||||
server?: boolean
|
||||
symlinkTarget?: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ConfirmRepairModal')
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instance.confirm-repair.header',
|
||||
defaultMessage: 'Repair {type}',
|
||||
},
|
||||
instanceBody: {
|
||||
id: 'instance.confirm-repair.body.instance',
|
||||
defaultMessage:
|
||||
'Repairing reinstalls the loader and Minecraft dependencies without deleting your content. This may resolve issues if your game is not launching due to launcher-related errors.',
|
||||
},
|
||||
serverBody: {
|
||||
id: 'instance.confirm-repair.body.server',
|
||||
defaultMessage:
|
||||
'Repairing reinstalls the loader and Minecraft dependencies without deleting your content. This may resolve issues if your server is not starting correctly.',
|
||||
},
|
||||
repairButton: {
|
||||
id: 'instance.confirm-repair.repair-button',
|
||||
defaultMessage: 'Repair',
|
||||
},
|
||||
instanceLabel: {
|
||||
id: 'instance.confirm-repair.instance-label',
|
||||
defaultMessage: 'instance',
|
||||
},
|
||||
serverLabel: {
|
||||
id: 'instance.confirm-repair.server-label',
|
||||
defaultMessage: 'server',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'repair'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function show() {
|
||||
debug('show: called', { hasModalRef: !!modal.value })
|
||||
modal.value?.show()
|
||||
debug('show: returned from modal.show', { hasModalRef: !!modal.value })
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
debug('confirm: called', { hasModalRef: !!modal.value })
|
||||
modal.value?.hide()
|
||||
emit('repair')
|
||||
debug('confirm: emitted repair')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<UnlinkIcon />
|
||||
{{ formatMessage(props.server ? messages.header : messages.unlinkButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UnlinkIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const props = defineProps<{
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-unlink.header',
|
||||
defaultMessage: 'Unlink modpack',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.confirm-unlink.admonition-header',
|
||||
defaultMessage: 'Unlinking modpack',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-unlink.admonition-body',
|
||||
defaultMessage:
|
||||
'Mods and content will be merged with what you added on top of the modpack, and it will stop receiving updates.',
|
||||
},
|
||||
unlinkButton: {
|
||||
id: 'content.confirm-unlink.unlink-button',
|
||||
defaultMessage: 'Unlink',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'unlink'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('unlink')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="560px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<SymlinkWarningAdmonition :symlink-target="symlinkTarget" />
|
||||
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
|
||||
<IntlFormatted
|
||||
v-if="visibleItems.length === 1"
|
||||
:message-id="messages.singleAdmonitionBody"
|
||||
:values="{ context: contextLabel }"
|
||||
>
|
||||
<template #project>
|
||||
<MinecraftFormattedText
|
||||
:text="visibleItems[0]?.project.title ?? formatMessage(commonMessages.unknownLabel)"
|
||||
/>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<template v-else>
|
||||
{{ formatMessage(messages.bulkAdmonitionBody, { context: contextLabel }) }}
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<div v-if="visibleItems.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.deletingLabel) }}</span>
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDeletingTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
ref="deletingListRef"
|
||||
class="flex flex-col gap-2 overflow-y-auto max-h-[212px]"
|
||||
@scroll="checkDeletingScrollState"
|
||||
>
|
||||
<div v-for="item in visibleItems" :key="item.id" :class="modalContentCardClasses">
|
||||
<ContentCardItem
|
||||
:project="item.project"
|
||||
:project-link="item.projectLink"
|
||||
:version="item.version"
|
||||
:version-link="item.versionLink"
|
||||
:owner="item.owner"
|
||||
hide-actions
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDeletingBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleDependents.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.affectedDependentsLabel, { count: visibleDependents.length })
|
||||
}}</span>
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDependentTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
ref="dependentListRef"
|
||||
class="flex max-h-[212px] flex-col gap-2 overflow-y-auto"
|
||||
@scroll="checkDependentScrollState"
|
||||
>
|
||||
<div
|
||||
v-for="dependent in visibleDependents"
|
||||
:key="dependent.item.id"
|
||||
:class="modalContentCardClasses"
|
||||
>
|
||||
<ContentCardItem
|
||||
:project="dependent.item.project"
|
||||
:project-link="dependent.item.projectLink"
|
||||
:version="dependent.item.version"
|
||||
:version-link="dependent.item.versionLink"
|
||||
:owner="dependent.item.owner"
|
||||
hide-actions
|
||||
inline
|
||||
>
|
||||
<template #title-badges>
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<span
|
||||
v-for="dependency in dependent.dependencies"
|
||||
:key="dependency.id"
|
||||
v-tooltip="{ content: autoToHTML(dependency.project.title), html: true }"
|
||||
>
|
||||
<span class="mr-0.5 truncate text-xs text-secondary">
|
||||
(<MinecraftFormattedText :text="dependency.project.title" />)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</ContentCardItem>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDependentBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.whatHappensLabel)
|
||||
}}</span>
|
||||
<ul class="m-0 list-disc pl-6 text-primary">
|
||||
<li class="leading-6 marker:text-secondary">
|
||||
{{ formatMessage(messages.effectDependentContent) }}
|
||||
</li>
|
||||
<li class="leading-6 marker:text-secondary">
|
||||
{{ formatMessage(messages.effectInstance, { context: contextLabel }) }}
|
||||
</li>
|
||||
</ul>
|
||||
<Checkbox
|
||||
v-model="disableDependentsAfterDeleting"
|
||||
:label="formatMessage(messages.disableDependentsLabel)"
|
||||
label-class="font-medium text-primary"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-5" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{ deleteButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { autoToHTML } from '@sfirew/minecraft-motd-parser'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
|
||||
import MinecraftFormattedText from '#ui/components/base/MinecraftFormattedText.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useScrollIndicator } from '#ui/composables/scroll-indicator'
|
||||
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
|
||||
|
||||
import type { ContentCardTableItem } from '../../types'
|
||||
import ContentCardItem from '../ContentCardItem.vue'
|
||||
import SymlinkWarningAdmonition from './SymlinkWarningAdmonition.vue'
|
||||
|
||||
export interface ContentDependencyWarningDependent {
|
||||
item: ContentCardTableItem
|
||||
dependencies: ContentCardTableItem[]
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items?: ContentCardTableItem[]
|
||||
dependents?: ContentDependencyWarningDependent[]
|
||||
itemType: string
|
||||
variant?: 'instance' | 'server'
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
symlinkTarget?: string
|
||||
}>(),
|
||||
{
|
||||
items: () => [],
|
||||
dependents: () => [],
|
||||
variant: 'instance',
|
||||
backupTip: undefined,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
symlinkTarget: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', disableDependentsAfterDeleting: boolean): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.dependency-warning.header',
|
||||
defaultMessage: 'Dependency warning',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.dependency-warning.admonition-header',
|
||||
defaultMessage: 'This content is required by other content',
|
||||
},
|
||||
singleAdmonitionBody: {
|
||||
id: 'content.dependency-warning.single-admonition-body',
|
||||
defaultMessage:
|
||||
'{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly.',
|
||||
},
|
||||
bulkAdmonitionBody: {
|
||||
id: 'content.dependency-warning.bulk-admonition-body',
|
||||
defaultMessage:
|
||||
'Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly.',
|
||||
},
|
||||
deletingLabel: {
|
||||
id: 'content.dependency-warning.deleting-label',
|
||||
defaultMessage: 'Deleting',
|
||||
},
|
||||
affectedDependentsLabel: {
|
||||
id: 'content.dependency-warning.affected-dependents-label',
|
||||
defaultMessage: 'Affected {count, plural, one {project} other {projects}}',
|
||||
},
|
||||
whatHappensLabel: {
|
||||
id: 'content.dependency-warning.what-happens-label',
|
||||
defaultMessage: 'What happens?',
|
||||
},
|
||||
effectDependentContent: {
|
||||
id: 'content.dependency-warning.effect-dependent-content',
|
||||
defaultMessage: 'Dependent content may fail to load or may disable itself',
|
||||
},
|
||||
effectInstance: {
|
||||
id: 'content.dependency-warning.effect-instance',
|
||||
defaultMessage: 'Your {context} may crash, refuse to start, or behave unexpectedly',
|
||||
},
|
||||
deleteAnywayButton: {
|
||||
id: 'content.dependency-warning.delete-anyway-button',
|
||||
defaultMessage: 'Delete anyway',
|
||||
},
|
||||
deleteManyAnywayButton: {
|
||||
id: 'content.dependency-warning.delete-many-anyway-button',
|
||||
defaultMessage: 'Delete {count, number} {itemType} anyway',
|
||||
},
|
||||
disableDependentsLabel: {
|
||||
id: 'content.dependency-warning.disable-dependents-label',
|
||||
defaultMessage: 'Disable dependents after deleting',
|
||||
},
|
||||
instanceContext: {
|
||||
id: 'content.dependency-warning.context.instance',
|
||||
defaultMessage: 'instance',
|
||||
},
|
||||
serverContext: {
|
||||
id: 'content.dependency-warning.context.server',
|
||||
defaultMessage: 'server',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const deletingListRef = ref<HTMLElement | null>(null)
|
||||
const dependentListRef = ref<HTMLElement | null>(null)
|
||||
const visibleItems = ref<ContentCardTableItem[]>(props.items)
|
||||
const visibleDependents = ref<ContentDependencyWarningDependent[]>(props.dependents)
|
||||
const visibleItemType = ref(props.itemType)
|
||||
const disableDependentsAfterDeleting = ref(false)
|
||||
const modalContentCardClasses = 'rounded-xl border border-solid border-surface-5 p-4 !bg-surface-2'
|
||||
const {
|
||||
showTopFade: showDeletingTopFade,
|
||||
showBottomFade: showDeletingBottomFade,
|
||||
checkScrollState: checkDeletingScrollState,
|
||||
forceCheck: forceCheckDeletingScroll,
|
||||
} = useScrollIndicator(deletingListRef)
|
||||
const {
|
||||
showTopFade: showDependentTopFade,
|
||||
showBottomFade: showDependentBottomFade,
|
||||
checkScrollState: checkDependentScrollState,
|
||||
forceCheck: forceCheckDependentScroll,
|
||||
} = useScrollIndicator(dependentListRef)
|
||||
|
||||
const contextLabel = computed(() =>
|
||||
formatMessage(props.variant === 'server' ? messages.serverContext : messages.instanceContext),
|
||||
)
|
||||
|
||||
const deleteButtonLabel = computed(() => {
|
||||
if (visibleItems.value.length <= 1) return formatMessage(messages.deleteAnywayButton)
|
||||
|
||||
return formatMessage(messages.deleteManyAnywayButton, {
|
||||
count: visibleItems.value.length,
|
||||
itemType: formatContentTypeSentence(
|
||||
formatMessage,
|
||||
visibleItemType.value,
|
||||
visibleItems.value.length,
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
async function show() {
|
||||
await nextTick()
|
||||
visibleItems.value = props.items
|
||||
visibleDependents.value = props.dependents
|
||||
visibleItemType.value = props.itemType
|
||||
disableDependentsAfterDeleting.value = false
|
||||
modal.value?.show()
|
||||
await nextTick()
|
||||
forceCheckDeletingScroll()
|
||||
forceCheckDependentScroll()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('delete', disableDependentsAfterDeleting.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,542 @@
|
||||
<template>
|
||||
<NewModal ref="modal" no-padding scrollable max-width="560px" width="560px" :on-hide="handleHide">
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.header) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<SymlinkWarningAdmonition :symlink-target="symlinkTarget" class="mx-6 mt-6" />
|
||||
<div
|
||||
v-if="projectInfo"
|
||||
class="flex items-center gap-2.5 rounded-[20px] bg-surface-2 mx-6 mt-6 p-3"
|
||||
>
|
||||
<AutoLink :to="projectInfo.link" class="shrink-0">
|
||||
<div
|
||||
class="size-14 shrink-0 overflow-hidden rounded-2xl border border-solid border-surface-5"
|
||||
>
|
||||
<Avatar
|
||||
v-if="projectInfo.iconUrl"
|
||||
:src="projectInfo.iconUrl"
|
||||
:alt="projectInfo.title"
|
||||
size="100%"
|
||||
no-shadow
|
||||
/>
|
||||
</div>
|
||||
</AutoLink>
|
||||
<div class="flex flex-col gap-1">
|
||||
<AutoLink :to="projectInfo.link" class="font-semibold text-contrast hover:underline">
|
||||
{{ projectInfo.title }}
|
||||
</AutoLink>
|
||||
<div v-if="projectInfo.owner" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<AutoLink
|
||||
:to="projectInfo.owner.link"
|
||||
class="flex items-center gap-1.5 text-inherit no-underline hover:underline"
|
||||
>
|
||||
<Avatar
|
||||
:src="projectInfo.owner.iconUrl"
|
||||
:alt="projectInfo.owner.name"
|
||||
size="1.25rem"
|
||||
:circle="projectInfo.owner.circle"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="font-medium">{{ projectInfo.owner.name }}</span>
|
||||
</AutoLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 p-6">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.instanceType) }}
|
||||
</span>
|
||||
<Chips
|
||||
v-model="tab"
|
||||
:items="tabs"
|
||||
:format-label="formatTabLabel"
|
||||
:never-empty="true"
|
||||
:capitalize="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-divider" />
|
||||
|
||||
<!-- Existing instance tab -->
|
||||
<div
|
||||
v-if="tab === 'existing'"
|
||||
class="flex flex-col gap-3 bg-surface-2 py-4"
|
||||
style="height: 400px; overflow-y: auto"
|
||||
>
|
||||
<div class="flex items-start gap-3 px-6">
|
||||
<StyledInput
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ButtonStyled type="outlined" circular>
|
||||
<button
|
||||
v-tooltip="`${hideUninstallable ? 'Show' : 'Hide'} unavailable`"
|
||||
@click="hideUninstallable = !hideUninstallable"
|
||||
>
|
||||
<EyeOffIcon v-if="hideUninstallable" />
|
||||
<EyeIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredInstances.length === 0"
|
||||
class="flex items-center justify-center py-12 text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noInstances) }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="inst in filteredInstances"
|
||||
:key="inst.id"
|
||||
class="flex items-center justify-between px-6 py-1.5"
|
||||
:class="inst.installed ? 'opacity-60' : 'hover:bg-surface-3'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="!inst.compatible ? formatMessage(messages.incompatibleTooltip) : undefined"
|
||||
class="flex min-w-0 cursor-pointer items-center gap-2.5 overflow-hidden border-0 bg-transparent p-0 text-left"
|
||||
@click="emit('navigate', inst)"
|
||||
>
|
||||
<Avatar
|
||||
:src="inst.iconUrl ?? undefined"
|
||||
size="2rem"
|
||||
rounded="md"
|
||||
:class="{
|
||||
'!border-0 !rounded-none !bg-transparent !shadow-none': inst.iconFrameless,
|
||||
}"
|
||||
/>
|
||||
<span class="truncate font-semibold text-contrast hover:underline">{{
|
||||
inst.name
|
||||
}}</span>
|
||||
</button>
|
||||
<ButtonStyled v-if="inst.installed">
|
||||
<button disabled>
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.installedBadge) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
:type="inst.compatible ? 'standard' : 'outlined'"
|
||||
:color="inst.compatible ? 'standard' : 'orange'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="!inst.compatible ? formatMessage(messages.incompatibleTooltip) : undefined"
|
||||
:disabled="inst.installing"
|
||||
@click="emit('install', inst)"
|
||||
>
|
||||
<TriangleAlertIcon v-if="!inst.compatible" />
|
||||
{{
|
||||
inst.installing
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: formatMessage(messages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New instance tab -->
|
||||
<div v-else class="flex flex-col gap-6 p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar
|
||||
:src="iconPreviewUrl ?? undefined"
|
||||
size="5rem"
|
||||
rounded="2xl"
|
||||
:class="{
|
||||
'!border-0 !rounded-none !bg-transparent !shadow-none': iconFrameless,
|
||||
}"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="selectIcon">
|
||||
<UploadIcon />
|
||||
{{ formatMessage(messages.selectIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="!iconPreviewUrl" @click="removeIcon">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.removeIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
</span>
|
||||
<StyledInput
|
||||
v-model="instanceName"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.loaderLabel) }}
|
||||
</span>
|
||||
<Chips
|
||||
v-model="selectedLoader"
|
||||
:items="compatibleLoaders"
|
||||
:format-label="formatLoaderLabel"
|
||||
:never-empty="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.gameVersionLabel) }}
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="selectedGameVersion"
|
||||
:options="gameVersionOptions"
|
||||
searchable
|
||||
sync-with-selection
|
||||
:placeholder="formatMessage(messages.gameVersionPlaceholder)"
|
||||
>
|
||||
<template v-if="hasReleaseData" #dropdown-footer>
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
|
||||
@mousedown.prevent
|
||||
@click="showSnapshots = !showSnapshots"
|
||||
>
|
||||
<EyeOffIcon v-if="showSnapshots" class="size-4" />
|
||||
<EyeIcon v-else class="size-4" />
|
||||
{{
|
||||
showSnapshots
|
||||
? formatMessage(commonMessages.hideSnapshotsButton)
|
||||
: formatMessage(commonMessages.showAllVersionsButton)
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div v-if="tab === 'existing'" class="flex items-center justify-between pt-5 pb-1 px-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<BoxIcon class="size-5" />
|
||||
<span>
|
||||
{{ formatMessage(messages.compatibleCount, { count: compatibleCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!instanceName" @click="handleCreateAndInstall">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
SearchIcon,
|
||||
TriangleAlertIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Chips from '#ui/components/base/Chips.vue'
|
||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectFilePicker } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import SymlinkWarningAdmonition from './SymlinkWarningAdmonition.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instances.content-install.header',
|
||||
defaultMessage: 'Install project',
|
||||
},
|
||||
instanceType: {
|
||||
id: 'instances.content-install.instance-type',
|
||||
defaultMessage: 'Instance type',
|
||||
},
|
||||
existingTab: {
|
||||
id: 'instances.content-install.existing-tab',
|
||||
defaultMessage: 'Existing instance',
|
||||
},
|
||||
newTab: {
|
||||
id: 'instances.content-install.new-tab',
|
||||
defaultMessage: 'New instance',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'instances.content-install.search-placeholder',
|
||||
defaultMessage: 'Search instance',
|
||||
},
|
||||
installedBadge: {
|
||||
id: 'instances.content-install.installed-badge',
|
||||
defaultMessage: 'Installed',
|
||||
},
|
||||
installButton: {
|
||||
id: 'instances.content-install.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
incompatibleTooltip: {
|
||||
id: 'instances.content-install.incompatible-tooltip',
|
||||
defaultMessage:
|
||||
'This instance uses a different loader or game version than this project supports.',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instances.content-install.select-icon',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instances.content-install.remove-icon',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'instances.content-install.name-label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'instances.content-install.name-placeholder',
|
||||
defaultMessage: 'Enter instance name',
|
||||
},
|
||||
loaderLabel: {
|
||||
id: 'instances.content-install.loader-label',
|
||||
defaultMessage: 'Loader',
|
||||
},
|
||||
gameVersionPlaceholder: {
|
||||
id: 'instances.content-install.game-version-placeholder',
|
||||
defaultMessage: 'Select game version',
|
||||
},
|
||||
compatibleCount: {
|
||||
id: 'instances.content-install.compatible-count',
|
||||
defaultMessage: '{count} compatible {count, plural, one {instance} other {instances}}',
|
||||
},
|
||||
noInstances: {
|
||||
id: 'instances.content-install.no-instances',
|
||||
defaultMessage: 'No compatible instances found',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ContentInstallInstance {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl?: string | null
|
||||
iconFrameless?: boolean
|
||||
installed: boolean
|
||||
compatible: boolean
|
||||
installing?: boolean
|
||||
}
|
||||
|
||||
export interface ContentInstallProjectOwner {
|
||||
name: string
|
||||
iconUrl?: string
|
||||
circle?: boolean
|
||||
link: string | (() => void)
|
||||
}
|
||||
|
||||
export interface ContentInstallProjectInfo {
|
||||
title: string
|
||||
iconUrl?: string | null
|
||||
link: string
|
||||
owner?: ContentInstallProjectOwner | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
instances: ContentInstallInstance[]
|
||||
compatibleLoaders: string[]
|
||||
gameVersions: string[]
|
||||
releaseGameVersions?: Set<string>
|
||||
loading?: boolean
|
||||
defaultTab?: 'existing' | 'new'
|
||||
preferredLoader?: string | null
|
||||
preferredGameVersion?: string | null
|
||||
projectInfo?: ContentInstallProjectInfo | null
|
||||
symlinkTarget?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
install: [instance: ContentInstallInstance]
|
||||
'create-and-install': [
|
||||
data: {
|
||||
name: string
|
||||
iconPath: string | null
|
||||
iconPreviewUrl: string | null
|
||||
loader: string
|
||||
gameVersion: string
|
||||
},
|
||||
]
|
||||
navigate: [instance: ContentInstallInstance]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
type Tab = 'existing' | 'new'
|
||||
const tabs = computed<Tab[]>(() =>
|
||||
props.compatibleLoaders.length > 0 ? ['existing', 'new'] : ['existing'],
|
||||
)
|
||||
const tab = ref<Tab>('existing')
|
||||
|
||||
const tabLabels: Record<Tab, () => string> = {
|
||||
existing: () => formatMessage(messages.existingTab),
|
||||
new: () => formatMessage(messages.newTab),
|
||||
}
|
||||
const formatTabLabel = (item: Tab) => tabLabels[item]()
|
||||
|
||||
const searchFilter = ref('')
|
||||
const hideUninstallable = ref(true)
|
||||
|
||||
const filteredInstances = computed(() => {
|
||||
let list = props.instances
|
||||
if (hideUninstallable.value) list = list.filter((i) => i.compatible && !i.installed)
|
||||
if (searchFilter.value) {
|
||||
const query = searchFilter.value.toLowerCase()
|
||||
list = list.filter((i) => i.name.toLowerCase().includes(query))
|
||||
}
|
||||
const score = (i: ContentInstallInstance) => (!i.compatible ? 2 : i.installed ? 1 : 0)
|
||||
return list.slice().sort((a, b) => {
|
||||
const diff = score(a) - score(b)
|
||||
if (diff !== 0) return diff
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
const compatibleCount = computed(() => props.instances.filter((i) => i.compatible).length)
|
||||
|
||||
const instanceName = ref('')
|
||||
const selectedLoader = ref<string | null>(null)
|
||||
const selectedGameVersion = ref<string | null>(null)
|
||||
const iconPath = ref<string | null>(null)
|
||||
const iconPreviewUrl = ref<string | null>(null)
|
||||
const iconFrameless = ref(false)
|
||||
const showSnapshots = ref(false)
|
||||
|
||||
const hasReleaseData = computed(
|
||||
() => props.releaseGameVersions && props.releaseGameVersions.size > 0,
|
||||
)
|
||||
|
||||
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
const versions =
|
||||
showSnapshots.value || !hasReleaseData.value
|
||||
? props.gameVersions
|
||||
: props.gameVersions.filter((v) => props.releaseGameVersions!.has(v))
|
||||
return versions.map((v) => ({ value: v, label: v }))
|
||||
})
|
||||
|
||||
const filePicker = injectFilePicker(null)
|
||||
|
||||
async function selectIcon() {
|
||||
if (!filePicker) return
|
||||
const picked = await (filePicker.pickInstanceIcon?.() ?? filePicker.pickImage())
|
||||
if (picked) {
|
||||
iconPath.value = picked.path ?? null
|
||||
iconPreviewUrl.value = picked.previewUrl
|
||||
iconFrameless.value = picked.frameless ?? false
|
||||
}
|
||||
}
|
||||
|
||||
function removeIcon() {
|
||||
iconPath.value = null
|
||||
iconPreviewUrl.value = null
|
||||
iconFrameless.value = false
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
tab.value = props.defaultTab ?? 'existing'
|
||||
searchFilter.value = ''
|
||||
hideUninstallable.value = false
|
||||
instanceName.value = `New instance (${props.instances.length + 1})`
|
||||
iconPath.value = null
|
||||
iconPreviewUrl.value = null
|
||||
iconFrameless.value = false
|
||||
selectedLoader.value = props.preferredLoader ?? props.compatibleLoaders[0] ?? null
|
||||
|
||||
const preferred = props.preferredGameVersion
|
||||
const isSnapshot = preferred && hasReleaseData.value && !props.releaseGameVersions!.has(preferred)
|
||||
showSnapshots.value = !!isSnapshot
|
||||
|
||||
const defaultVersion = hasReleaseData.value
|
||||
? (props.gameVersions.find((v) => props.releaseGameVersions!.has(v)) ??
|
||||
props.gameVersions[0] ??
|
||||
null)
|
||||
: (props.gameVersions[0] ?? null)
|
||||
selectedGameVersion.value = preferred ?? defaultVersion
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading, wasLoading) => {
|
||||
if (wasLoading && !loading) {
|
||||
tab.value = props.defaultTab ?? 'existing'
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function handleHide() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function show() {
|
||||
resetState()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleCreateAndInstall() {
|
||||
if (!instanceName.value || !selectedLoader.value || !selectedGameVersion.value) return
|
||||
emit('create-and-install', {
|
||||
name: instanceName.value,
|
||||
iconPath: iconPath.value,
|
||||
iconPreviewUrl: iconPreviewUrl.value,
|
||||
loader: selectedLoader.value,
|
||||
gameVersion: selectedGameVersion.value,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@ -0,0 +1,603 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
FilterIcon,
|
||||
GlassesIcon,
|
||||
PaintbrushIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, nextTick, ref, watchSyncEffect } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
commonMessages,
|
||||
commonProjectTypeCategoryMessages,
|
||||
commonProjectTypeTitleMessages,
|
||||
normalizeProjectType,
|
||||
} from '#ui/utils/common-messages'
|
||||
|
||||
import {
|
||||
canToggleContentItem,
|
||||
getClientWarningType,
|
||||
isClientOnlyEnvironment,
|
||||
isDisabledContentItem,
|
||||
isEnabledContentItem,
|
||||
} from '../../composables/content-filtering'
|
||||
import type { ContentCardTableItem, ContentItem } from '../../types'
|
||||
import ContentCardTable from '../ContentCardTable.vue'
|
||||
import ContentSelectionBar from '../ContentSelectionBar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
interface Props {
|
||||
modpackName?: string
|
||||
modpackIconUrl?: string
|
||||
enableToggle?: boolean
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string | null
|
||||
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modpackName: undefined,
|
||||
modpackIconUrl: undefined,
|
||||
enableToggle: false,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
getOverflowOptions: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [item: ContentItem, value: boolean]
|
||||
'bulk:enable': [items: ContentItem[]]
|
||||
'bulk:disable': [items: ContentItem[]]
|
||||
hide: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instances.modpack-content-modal.header',
|
||||
defaultMessage: 'Modpack content',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'instances.modpack-content-modal.search-placeholder',
|
||||
defaultMessage: 'Search {count, number} {count, plural, one {project} other {projects}}',
|
||||
},
|
||||
loading: {
|
||||
id: 'instances.modpack-content-modal.loading',
|
||||
defaultMessage: 'Loading content...',
|
||||
},
|
||||
emptyTitle: {
|
||||
id: 'instances.modpack-content-modal.empty-title',
|
||||
defaultMessage: 'No content found',
|
||||
},
|
||||
emptyDescription: {
|
||||
id: 'instances.modpack-content-modal.empty-description',
|
||||
defaultMessage: 'This modpack does not include any additional content.',
|
||||
},
|
||||
noResults: {
|
||||
id: 'instances.modpack-content-modal.no-results',
|
||||
defaultMessage: 'No projects match your search.',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ModpackContentModalState {
|
||||
items: ContentItem[]
|
||||
searchQuery: string
|
||||
selectedFilters: string[]
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const items = ref<ContentItem[]>([])
|
||||
const disabledIds = ref(new Set<string>())
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const selectedFilters = ref<string[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
items.value.filter((item) => selectedIds.value.includes(item.file_name)),
|
||||
)
|
||||
|
||||
const allSelected = computed(() => {
|
||||
if (filteredItems.value.length === 0) return false
|
||||
return filteredItems.value.every((item) => selectedIds.value.includes(item.file_name))
|
||||
})
|
||||
|
||||
const someSelected = computed(() => {
|
||||
return (
|
||||
filteredItems.value.some((item) => selectedIds.value.includes(item.file_name)) &&
|
||||
!allSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value || someSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = filteredItems.value.map((item) => item.file_name)
|
||||
}
|
||||
}
|
||||
|
||||
const fuse = new Fuse<ContentItem>([], {
|
||||
keys: ['project.title', 'owner.name', 'file_name'],
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
})
|
||||
|
||||
watchSyncEffect(() => fuse.setCollection(items.value))
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const frequency = items.value.reduce(
|
||||
(map, item) => {
|
||||
const normalized = normalizeProjectType(item.project_type)
|
||||
map[normalized] = (map[normalized] || 0) + 1
|
||||
return map
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
|
||||
const options = Object.entries(frequency)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([type]) => {
|
||||
const msg =
|
||||
commonProjectTypeCategoryMessages[type as keyof typeof commonProjectTypeCategoryMessages]
|
||||
return {
|
||||
id: type,
|
||||
label: msg ? formatMessage(msg) : type.charAt(0).toUpperCase() + type.slice(1) + 's',
|
||||
}
|
||||
})
|
||||
|
||||
if (items.value.some((item) => getClientWarningType(item) !== null)) {
|
||||
options.push({ id: 'warnings', label: 'Warnings' })
|
||||
}
|
||||
|
||||
if (items.value.some(isDisabledContentItem)) {
|
||||
options.push({ id: 'disabled', label: 'Disabled' })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const item of items.value) {
|
||||
const normalized = normalizeProjectType(item.project_type)
|
||||
counts[normalized] = (counts[normalized] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
function toggleFilter(filterId: string) {
|
||||
const index = selectedFilters.value.indexOf(filterId)
|
||||
if (index === -1) {
|
||||
selectedFilters.value.push(filterId)
|
||||
} else {
|
||||
selectedFilters.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const attributeFilterIds = new Set(['disabled', 'warnings'])
|
||||
|
||||
const typeFilteredCount = computed(() => {
|
||||
if (selectedFilters.value.length === 0) return items.value.length
|
||||
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
|
||||
const hasDisabledFilter = selectedFilters.value.includes('disabled')
|
||||
const hasWarningsFilter = selectedFilters.value.includes('warnings')
|
||||
return items.value.filter((item) => {
|
||||
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
|
||||
return false
|
||||
if (hasDisabledFilter && !isDisabledContentItem(item)) return false
|
||||
if (hasWarningsFilter && getClientWarningType(item) === null) return false
|
||||
return true
|
||||
}).length
|
||||
})
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
|
||||
let result: ContentItem[]
|
||||
if (query) {
|
||||
result = fuse.search(query).map(({ item }) => item)
|
||||
} else {
|
||||
result = [...items.value].sort((a, b) => {
|
||||
const nameA = a.project?.title ?? a.file_name
|
||||
const nameB = b.project?.title ?? b.file_name
|
||||
return nameA.toLowerCase().localeCompare(nameB.toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedFilters.value.length > 0) {
|
||||
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
|
||||
const hasDisabledFilter = selectedFilters.value.includes('disabled')
|
||||
const hasWarningsFilter = selectedFilters.value.includes('warnings')
|
||||
result = result.filter((item) => {
|
||||
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
|
||||
return false
|
||||
if (hasDisabledFilter && !isDisabledContentItem(item)) return false
|
||||
if (hasWarningsFilter && getClientWarningType(item) === null) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function dependencyBadgeFor(item: ContentItem) {
|
||||
const dependency = item.dependency
|
||||
if (!dependency || (!dependency.autoDependency && dependency.requiredBy.length === 0)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
autoDependency: dependency.autoDependency,
|
||||
orphaned: dependency.orphaned,
|
||||
}
|
||||
}
|
||||
|
||||
const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
filteredItems.value.map((item) => ({
|
||||
id: item.file_name,
|
||||
project: item.project ?? {
|
||||
id: item.file_name,
|
||||
slug: null,
|
||||
title: item.file_name,
|
||||
icon_url: null,
|
||||
},
|
||||
projectLink:
|
||||
item.project?.id && !item.project.id.startsWith('local:')
|
||||
? `/project/${item.project.id}`
|
||||
: undefined,
|
||||
version: item.version ?? {
|
||||
id: item.file_name,
|
||||
version_number: 'Unknown',
|
||||
file_name: item.file_name,
|
||||
},
|
||||
owner: item.owner
|
||||
? {
|
||||
...item.owner,
|
||||
link: `https://modrinth.com/${item.owner.type}/${item.owner.id}`,
|
||||
}
|
||||
: undefined,
|
||||
...(props.enableToggle ? { enabled: item.enabled } : {}),
|
||||
installing: item.installing === true,
|
||||
toggleDisabled: props.actionDisabled || !canToggleContentItem(item),
|
||||
toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
|
||||
dependencyBadge: dependencyBadgeFor(item),
|
||||
isClientOnly:
|
||||
isClientOnlyEnvironment(item.environment) ||
|
||||
!!item.pack_client_retained ||
|
||||
!!item.pack_client_depends,
|
||||
clientWarning: getClientWarningType(item),
|
||||
disabled:
|
||||
props.actionDisabled || disabledIds.value.has(item.file_name) || item.installing === true,
|
||||
disabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
|
||||
overflowOptions: [...(props.getOverflowOptions?.(item) ?? [])],
|
||||
})),
|
||||
)
|
||||
|
||||
function getTypeIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'mod':
|
||||
return BoxIcon
|
||||
case 'shaderpack':
|
||||
case 'shader':
|
||||
return GlassesIcon
|
||||
case 'resourcepack':
|
||||
return PaintbrushIcon
|
||||
default:
|
||||
return BoxIcon
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnabledChange(fileName: string, value: boolean) {
|
||||
if (props.actionDisabled) return
|
||||
const item = items.value.find((i) => i.file_name === fileName)
|
||||
if (!item) return
|
||||
emit('update:enabled', item, value)
|
||||
}
|
||||
|
||||
function bulkEnable() {
|
||||
if (props.actionDisabled) return
|
||||
emit(
|
||||
'bulk:enable',
|
||||
selectedItems.value.filter((item) => canToggleContentItem(item) && isDisabledContentItem(item)),
|
||||
)
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function bulkDisable() {
|
||||
if (props.actionDisabled) return
|
||||
emit(
|
||||
'bulk:disable',
|
||||
selectedItems.value.filter((item) => canToggleContentItem(item) && isEnabledContentItem(item)),
|
||||
)
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function show(contentItems: ContentItem[]) {
|
||||
items.value = contentItems.map((item) => ({ ...item }))
|
||||
searchQuery.value = ''
|
||||
selectedFilters.value = []
|
||||
selectedIds.value = []
|
||||
disabledIds.value = new Set()
|
||||
loading.value = false
|
||||
showModal()
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
items.value = []
|
||||
searchQuery.value = ''
|
||||
selectedFilters.value = []
|
||||
selectedIds.value = []
|
||||
loading.value = true
|
||||
showModal()
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
if (isOpen.value || !modal.value) return
|
||||
isOpen.value = true
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
isOpen.value = false
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
isOpen.value = false
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
function getState(): ModpackContentModalState | null {
|
||||
if (!items.value.length) return null
|
||||
return {
|
||||
items: items.value,
|
||||
searchQuery: searchQuery.value,
|
||||
selectedFilters: [...selectedFilters.value],
|
||||
scrollTop: scrollContainer.value?.scrollTop ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(state: ModpackContentModalState) {
|
||||
items.value = state.items.map((item) => ({ ...item }))
|
||||
searchQuery.value = state.searchQuery
|
||||
selectedFilters.value = state.selectedFilters
|
||||
loading.value = false
|
||||
showModal()
|
||||
await nextTick()
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.scrollTop = state.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
function updateItem(fileName: string, updates: Partial<ContentItem> & { disabled?: boolean }) {
|
||||
if (updates.disabled !== undefined) {
|
||||
const newSet = new Set(disabledIds.value)
|
||||
if (updates.disabled) {
|
||||
newSet.add(fileName)
|
||||
} else {
|
||||
newSet.delete(fileName)
|
||||
}
|
||||
disabledIds.value = newSet
|
||||
}
|
||||
const { disabled: _, ...itemUpdates } = updates
|
||||
if (Object.keys(itemUpdates).length > 0) {
|
||||
items.value = items.value.map((item) =>
|
||||
item.file_name === fileName ? { ...item, ...itemUpdates } : item,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function setItems(contentItems: ContentItem[]) {
|
||||
const contentFileNames = new Set(contentItems.map((item) => item.file_name))
|
||||
items.value = contentItems.map((item) => ({ ...item }))
|
||||
selectedIds.value = selectedIds.value.filter((id) => contentFileNames.has(id))
|
||||
disabledIds.value = new Set([...disabledIds.value].filter((id) => contentFileNames.has(id)))
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:max-width="'min(928px, calc(95vw - 10rem))'"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
:on-hide="handleHide"
|
||||
no-padding
|
||||
>
|
||||
<template #title>
|
||||
<Avatar
|
||||
v-if="props.modpackIconUrl"
|
||||
:src="props.modpackIconUrl"
|
||||
size="3rem"
|
||||
:tint-by="props.modpackName"
|
||||
/>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.header) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col h-[min(600px,calc(95vh-10rem))]">
|
||||
<div class="flex flex-col gap-4 px-6 py-4 border-b border-solid border-0 border-surface-4">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder, { count: typeFilteredCount })"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<!-- Filters -->
|
||||
<div v-if="filterOptions.length > 0" class="flex items-center gap-2">
|
||||
<FilterIcon class="size-5 text-secondary shrink-0" />
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
:aria-pressed="selectedFilters.length === 0"
|
||||
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
|
||||
:class="
|
||||
selectedFilters.length === 0
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
||||
"
|
||||
@click="selectedFilters = []"
|
||||
>
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
:aria-pressed="selectedFilters.includes(option.id)"
|
||||
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
|
||||
:class="
|
||||
selectedFilters.includes(option.id)
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
||||
"
|
||||
@click="toggleFilter(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center flex-1 gap-2 text-secondary"
|
||||
>
|
||||
<SpinnerIcon class="size-8 animate-spin" />
|
||||
<span class="text-sm">{{ formatMessage(messages.loading) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
class="flex flex-col items-center justify-center flex-1 gap-2 text-center p-8"
|
||||
>
|
||||
<span class="text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.emptyTitle) }}
|
||||
</span>
|
||||
<span class="text-secondary">{{ formatMessage(messages.emptyDescription) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- No search results -->
|
||||
<div
|
||||
v-else-if="filteredItems.length === 0"
|
||||
class="flex flex-col items-center justify-center flex-1 gap-2 text-center p-8"
|
||||
>
|
||||
<span class="text-secondary">{{ formatMessage(messages.noResults) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Content table -->
|
||||
<div v-else class="@container flex-1 min-h-0 flex flex-col">
|
||||
<div
|
||||
class="flex h-12 shrink-0 items-center justify-between gap-4 border-0 border-b border-solid border-surface-4 bg-surface-3 px-3"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
props.enableToggle
|
||||
? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="props.enableToggle"
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
:aria-label="formatMessage(commonMessages.selectAllLabel)"
|
||||
class="shrink-0"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.projectLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
class="hidden @[800px]:flex"
|
||||
:class="props.enableToggle ? 'flex-1 min-w-0' : 'flex-1'"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.versionLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="props.enableToggle" class="min-w-[160px] shrink-0 text-right">
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.actionsLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="scrollContainer" class="flex-1 min-h-0 overflow-y-auto">
|
||||
<ContentCardTable
|
||||
v-model:selected-ids="selectedIds"
|
||||
:items="tableItems"
|
||||
:show-selection="props.enableToggle"
|
||||
hide-delete
|
||||
hide-header
|
||||
flat
|
||||
v-on="
|
||||
props.enableToggle
|
||||
? { 'update:enabled': (id: string, val: boolean) => handleEnabledChange(id, val) }
|
||||
: {}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-t border-solid border-0 border-surface-4 shrink-0"
|
||||
>
|
||||
<!-- Stats -->
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-for="(count, type, idx) in stats" :key="type">
|
||||
<BulletDivider v-if="idx > 0" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<component :is="getTypeIcon(type as string)" class="size-5 text-secondary" />
|
||||
<span class="font-medium text-primary">
|
||||
{{ count }}
|
||||
{{
|
||||
formatMessage(
|
||||
commonProjectTypeTitleMessages[
|
||||
normalizeProjectType(
|
||||
type as string,
|
||||
) as keyof typeof commonProjectTypeTitleMessages
|
||||
] ?? commonProjectTypeTitleMessages.project,
|
||||
{ count },
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentSelectionBar
|
||||
v-if="props.enableToggle"
|
||||
:selected-items="selectedItems"
|
||||
:is-busy="props.actionDisabled"
|
||||
:busy-tooltip="props.actionDisabledTooltip"
|
||||
style="--left-bar-width: 0px; --right-bar-width: 0px"
|
||||
@clear="selectedIds = []"
|
||||
@enable="bulkEnable"
|
||||
@disable="bulkDisable"
|
||||
/>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<Admonition v-if="symlinkTarget" type="warning" :header="formatMessage(messages.header)">
|
||||
{{ formatMessage(messages.body, { path: symlinkTarget }) }}
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.symlink-warning.write.header',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
body: {
|
||||
id: 'app.symlink-warning.write.body',
|
||||
defaultMessage:
|
||||
'This instance is linked to "{path}". Changes will also affect the original files.',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
symlinkTarget?: string
|
||||
}>()
|
||||
</script>
|
||||
@ -0,0 +1,815 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:max-width="showSimplifiedWarning ? '480px' : 'min(928px, calc(95vw - 10rem))'"
|
||||
:width="showSimplifiedWarning ? '480px' : 'min(928px, calc(95vw - 10rem))'"
|
||||
:on-hide="handleModalHide"
|
||||
:no-padding="!showSimplifiedWarning"
|
||||
>
|
||||
<template #title>
|
||||
<Avatar v-if="projectIconUrl" :src="projectIconUrl" size="3rem" :tint-by="projectName" />
|
||||
<span class="text-lg font-extrabold text-contrast">{{ header ?? defaultHeader }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Simplified warning when no version data is available (e.g. drag & drop) -->
|
||||
<template v-if="showSimplifiedWarning">
|
||||
<div class="p-6">
|
||||
<Admonition type="warning" :body="warning ?? ''" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Full version picker layout -->
|
||||
<template v-else>
|
||||
<div
|
||||
class="flex h-[min(550px,calc(95vh-10rem))] border-solid border-transparent border-[1px] border-b-surface-4"
|
||||
>
|
||||
<div class="w-[300px] flex flex-col relative bg-surface-3">
|
||||
<div class="p-4 pb-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchVersionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4" :class="isModpack ? 'pb-4' : 'pb-16'">
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center h-full gap-2">
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
<span class="text-sm text-secondary">{{
|
||||
formatMessage(messages.loadingVersions)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-else class="min-h-full">
|
||||
<div class="flex flex-col gap-1.5" role="listbox">
|
||||
<button
|
||||
v-for="version in filteredVersions"
|
||||
:key="version.id"
|
||||
role="option"
|
||||
:aria-selected="selectedVersion?.id === version.id"
|
||||
class="flex items-center h-10 px-4 py-2.5 rounded-xl border-none cursor-pointer transition-colors"
|
||||
:class="[
|
||||
selectedVersion?.id === version.id
|
||||
? 'bg-brand-highlight'
|
||||
: 'bg-transparent hover:bg-button-bg',
|
||||
]"
|
||||
@mouseenter="handleVersionMouseEnter(version)"
|
||||
@mouseleave="handleVersionMouseLeave"
|
||||
@focus="emit('versionHover', version)"
|
||||
@click="handleVersionSelect(version)"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<VersionChannelIndicator
|
||||
:channel="version.version_type"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-tooltip="version.version_number"
|
||||
class="font-semibold text-contrast truncate"
|
||||
>
|
||||
{{ version.version_number }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="shouldShowBadge(version)"
|
||||
class="rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
|
||||
:class="[
|
||||
getBadgeClasses(version),
|
||||
shouldShowIncompatibleBadge(version) ? 'p-1' : 'px-2.5 py-0.5',
|
||||
]"
|
||||
>
|
||||
<CircleAlertIcon
|
||||
v-if="shouldShowIncompatibleBadge(version)"
|
||||
v-tooltip="formatMessage(messages.incompatibleBadge)"
|
||||
class="size-4"
|
||||
/>
|
||||
<template v-else>{{ getBadgeLabel(version) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredVersions.length === 0"
|
||||
class="p-4 text-center text-secondary text-sm"
|
||||
>
|
||||
{{ formatMessage(messages.noVersionsFound) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isModpack && !incompatibilityWarningMode"
|
||||
class="absolute bottom-0 left-0 right-0 pointer-events-none flex flex-col items-center justify-end bg-gradient-to-b from-transparent to-bg-raised to-70% pb-3 h-24"
|
||||
>
|
||||
<div class="pointer-events-auto">
|
||||
<ButtonStyled type="transparent" :circular="true">
|
||||
<button
|
||||
class="flex items-center gap-1.5"
|
||||
:aria-label="
|
||||
hideIncompatibleState
|
||||
? formatMessage(messages.showIncompatible)
|
||||
: formatMessage(messages.hideIncompatible)
|
||||
"
|
||||
@click="hideIncompatibleState = !hideIncompatibleState"
|
||||
>
|
||||
<EyeIcon v-if="hideIncompatibleState" class="h-6 w-6" />
|
||||
<EyeOffIcon v-else class="h-6 w-6" />
|
||||
<span class="font-medium">{{
|
||||
hideIncompatibleState
|
||||
? formatMessage(messages.showIncompatible)
|
||||
: formatMessage(messages.hideIncompatible)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-px bg-divider" />
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0 min-h-0 relative bg-surface-1" aria-live="polite">
|
||||
<div v-if="selectedVersion" class="flex-1 flex flex-col min-w-0 min-h-0 relative">
|
||||
<div class="bg-bg p-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-xl text-contrast">
|
||||
{{ selectedVersion.version_number }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2.5 py-0.5 rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
|
||||
:class="getVersionTypeBadgeClasses(selectedVersion)"
|
||||
>
|
||||
{{ capitalizeString(selectedVersion.version_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-primary">
|
||||
{{ formatLongDate(selectedVersion.date_published) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 rounded-xl">
|
||||
<FileTextIcon class="h-6 w-6 text-primary" />
|
||||
<span class="font-medium text-primary">{{
|
||||
formatMessage(commonMessages.changelogLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-divider" />
|
||||
<span class="font-medium text-primary">
|
||||
{{ formatLoaderGameVersion(selectedVersion) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-divider" />
|
||||
|
||||
<div class="flex-1 min-h-0 bg-bg p-4 overflow-y-auto">
|
||||
<div
|
||||
v-if="loadingChangelog"
|
||||
class="flex flex-col items-center justify-center h-full gap-2"
|
||||
>
|
||||
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
|
||||
<span class="text-sm text-secondary">{{
|
||||
formatMessage(messages.loadingChangelog)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="selectedVersion.changelog"
|
||||
class="markdown [&_img]:max-w-full [&_img]:h-auto"
|
||||
v-html="renderHighlightedString(selectedVersion.changelog)"
|
||||
/>
|
||||
<div v-else class="text-secondary italic">
|
||||
{{ formatMessage(messages.noChangelog) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-14 bg-gradient-to-t from-bg to-transparent pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loading || loadingChangelog || props.versions.length > 0"
|
||||
class="flex-1 flex flex-col items-center justify-center h-full gap-2 text-secondary bg-bg"
|
||||
>
|
||||
<SpinnerIcon class="h-6 w-6 animate-spin" />
|
||||
<span class="text-sm">{{ formatMessage(messages.loadingChangelog) }}</span>
|
||||
</div>
|
||||
<div v-else class="flex-1 flex items-center justify-center text-secondary bg-bg">
|
||||
{{ formatMessage(messages.selectVersionPrompt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full flex flex-row items-center gap-4 p-4 border-solid border-x-0 border-b-0 border-t border-surface-4"
|
||||
>
|
||||
<div
|
||||
v-if="showUpdateWarning"
|
||||
class="flex flex-row items-center gap-2 max-w-[55%] flex-1 text-orange mr-auto"
|
||||
>
|
||||
<TriangleAlertIcon class="size-6 shrink-0" />
|
||||
<span>{{
|
||||
warning ??
|
||||
formatMessage(
|
||||
incompatibilityWarningMode
|
||||
? messages.incompatibilityWarning
|
||||
: isApp
|
||||
? messages.updateWarningApp
|
||||
: messages.updateWarningWeb,
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 shrink-0 ml-auto">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :color="incompatibilityWarningMode ? 'orange' : 'brand'">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="
|
||||
actionLoading ||
|
||||
props.actionDisabled ||
|
||||
!selectedVersion ||
|
||||
(!incompatibilityWarningMode && selectedVersion.id === currentVersionId)
|
||||
"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
<SpinnerIcon v-if="actionLoading" class="size-5 animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
actionLoading
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: incompatibilityWarningMode
|
||||
? formatMessage(messages.installAnywayButton)
|
||||
: formatMessage(
|
||||
isDowngrade
|
||||
? messages.downgradeToVersion
|
||||
: switchMode
|
||||
? messages.switchToVersion
|
||||
: messages.updateToVersion,
|
||||
{
|
||||
version: selectedVersion?.version_number ?? '...',
|
||||
},
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Simplified warning mode actions -->
|
||||
<template v-if="showSimplifiedWarning" #actions>
|
||||
<div class="flex gap-3 w-full">
|
||||
<ButtonStyled>
|
||||
<button @click="handleCancel">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleSearchCompat">
|
||||
<SearchIcon class="size-4" />
|
||||
{{ formatMessage(messages.searchCompatButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="actionLoading" @click="handleUpdate">
|
||||
<SpinnerIcon v-if="actionLoading" class="size-5 animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{ formatMessage(messages.installAnywayButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<ConfirmModal
|
||||
ref="incompatibleUpdateModal"
|
||||
:title="formatMessage(messages.incompatibleUpdateHeader)"
|
||||
:description="
|
||||
formatMessage(messages.incompatibleUpdateDescription, {
|
||||
version: pendingIncompatibleUpdate?.version.version_number ?? '...',
|
||||
})
|
||||
"
|
||||
:proceed-icon="DownloadIcon"
|
||||
:proceed-label="formatMessage(messages.updateAnywayButton)"
|
||||
:danger="false"
|
||||
:markdown="false"
|
||||
@proceed="confirmIncompatibleUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
TriangleAlertIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
capitalizeString,
|
||||
formatVersionsForDisplay,
|
||||
type GameVersionTag,
|
||||
renderHighlightedString,
|
||||
} from '@modrinth/utils'
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import ConfirmModal from '#ui/components/modal/ConfirmModal.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import VersionChannelIndicator from '#ui/components/version/VersionChannelIndicator.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectTags } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import {
|
||||
versionChangesGameVersion,
|
||||
versionMatchesCompatibilityTarget,
|
||||
} from '#ui/utils/version-compatibility'
|
||||
|
||||
import { useContentUpdaterFiltering } from './use-content-updater-filtering'
|
||||
import { useContentUpdaterSelection } from './use-content-updater-selection'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ContentUpdaterModal')
|
||||
const tags = injectTags(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
updateVersionHeader: {
|
||||
id: 'instances.updater-modal.header',
|
||||
defaultMessage: 'Update version',
|
||||
},
|
||||
incompatibilityWarningHeader: {
|
||||
id: 'instances.updater-modal.incompatibility-warning-header',
|
||||
defaultMessage: 'Choose version',
|
||||
},
|
||||
switchModpackVersionHeader: {
|
||||
id: 'instances.updater-modal.header-modpack',
|
||||
defaultMessage: 'Switch modpack version',
|
||||
},
|
||||
searchVersionPlaceholder: {
|
||||
id: 'instances.updater-modal.search-placeholder',
|
||||
defaultMessage: 'Search version...',
|
||||
},
|
||||
noVersionsFound: {
|
||||
id: 'instances.updater-modal.no-versions',
|
||||
defaultMessage: 'No versions found',
|
||||
},
|
||||
showIncompatible: {
|
||||
id: 'instances.updater-modal.show-incompatible',
|
||||
defaultMessage: 'Show incompatible',
|
||||
},
|
||||
hideIncompatible: {
|
||||
id: 'instances.updater-modal.hide-incompatible',
|
||||
defaultMessage: 'Hide incompatible',
|
||||
},
|
||||
noChangelog: {
|
||||
id: 'instances.updater-modal.no-changelog',
|
||||
defaultMessage: 'No changelog provided for this version.',
|
||||
},
|
||||
selectVersionPrompt: {
|
||||
id: 'instances.updater-modal.select-version',
|
||||
defaultMessage: 'Select a version to view its changelog',
|
||||
},
|
||||
updateWarningApp: {
|
||||
id: 'instances.updater-modal.warning-app',
|
||||
defaultMessage:
|
||||
'Updating can break your instance. Review version changelogs and back up first.',
|
||||
},
|
||||
updateWarningWeb: {
|
||||
id: 'instances.updater-modal.warning-web',
|
||||
defaultMessage: 'Updating can break your world. Review version changelogs and back up first.',
|
||||
},
|
||||
incompatibilityWarning: {
|
||||
id: 'instances.updater-modal.incompatibility-warning',
|
||||
defaultMessage:
|
||||
'This version is not marked as compatible with this instance. Dependencies will not be installed automatically.',
|
||||
},
|
||||
downgradeToVersion: {
|
||||
id: 'instances.updater-modal.downgrade-to',
|
||||
defaultMessage: 'Downgrade to {version}',
|
||||
},
|
||||
updateToVersion: {
|
||||
id: 'instances.updater-modal.update-to',
|
||||
defaultMessage: 'Update to {version}',
|
||||
},
|
||||
switchVersionHeader: {
|
||||
id: 'instances.updater-modal.header-switch',
|
||||
defaultMessage: 'Switch version',
|
||||
},
|
||||
switchToVersion: {
|
||||
id: 'instances.updater-modal.switch-to',
|
||||
defaultMessage: 'Switch to {version}',
|
||||
},
|
||||
currentBadge: {
|
||||
id: 'instances.updater-modal.badge.current',
|
||||
defaultMessage: 'Current',
|
||||
},
|
||||
incompatibleBadge: {
|
||||
id: 'instances.updater-modal.badge.incompatible',
|
||||
defaultMessage: 'Incompatible',
|
||||
},
|
||||
loadingVersions: {
|
||||
id: 'instances.updater-modal.loading-versions',
|
||||
defaultMessage: 'Loading versions...',
|
||||
},
|
||||
loadingChangelog: {
|
||||
id: 'instances.updater-modal.loading-changelog',
|
||||
defaultMessage: 'Loading changelog...',
|
||||
},
|
||||
incompatibleUpdateHeader: {
|
||||
id: 'instances.updater-modal.incompatible-update.header',
|
||||
defaultMessage: 'Update to incompatible version?',
|
||||
},
|
||||
incompatibleUpdateDescription: {
|
||||
id: 'instances.updater-modal.incompatible-update.description',
|
||||
defaultMessage:
|
||||
'{version} is not marked as compatible with this installation. It may fail to launch or behave unexpectedly.',
|
||||
},
|
||||
updateAnywayButton: {
|
||||
id: 'instances.updater-modal.incompatible-update.proceed',
|
||||
defaultMessage: 'Update anyway',
|
||||
},
|
||||
installAnywayButton: {
|
||||
id: 'instances.updater-modal.install-anyway',
|
||||
defaultMessage: 'Install anyway',
|
||||
},
|
||||
searchCompatButton: {
|
||||
id: 'instances.updater-modal.search-compat',
|
||||
defaultMessage: 'Find compatible version',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
versions: Labrinth.Versions.v2.Version[]
|
||||
currentGameVersion: string
|
||||
currentLoader: string
|
||||
currentVersionId: string
|
||||
isApp: boolean
|
||||
/** The project type (e.g. mod, shader, resourcepack, datapack, modpack). */
|
||||
projectType?: string
|
||||
projectIconUrl?: string
|
||||
projectName?: string
|
||||
header?: string
|
||||
mode?: 'version' | 'incompatibility-warning'
|
||||
warning?: string
|
||||
actionLoading?: boolean
|
||||
/** Whether versions are currently being loaded */
|
||||
loading?: boolean
|
||||
/** Whether changelog is being loaded for the selected version */
|
||||
loadingChangelog?: boolean
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
/** When set in incompatibility-warning mode, adds a "Find compatible version" button */
|
||||
searchHref?: string
|
||||
}>(),
|
||||
{
|
||||
projectType: undefined,
|
||||
projectIconUrl: undefined,
|
||||
projectName: undefined,
|
||||
header: undefined,
|
||||
mode: 'version',
|
||||
warning: undefined,
|
||||
actionLoading: false,
|
||||
loading: false,
|
||||
loadingChangelog: false,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
searchHref: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const isModpack = computed(() => props.projectType === 'modpack')
|
||||
const incompatibilityWarningMode = computed(() => props.mode === 'incompatibility-warning')
|
||||
/** Simplified warning when in incompatibility-warning mode without version data */
|
||||
const showSimplifiedWarning = computed(
|
||||
() => incompatibilityWarningMode.value && props.versions.length === 0 && props.warning,
|
||||
)
|
||||
const defaultHeader = computed(() => {
|
||||
if (incompatibilityWarningMode.value) {
|
||||
return formatMessage(messages.incompatibilityWarningHeader)
|
||||
}
|
||||
|
||||
return formatMessage(
|
||||
isModpack.value
|
||||
? messages.switchModpackVersionHeader
|
||||
: switchMode.value
|
||||
? messages.switchVersionHeader
|
||||
: messages.updateVersionHeader,
|
||||
)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [version: Labrinth.Versions.v2.Version, event: MouseEvent]
|
||||
cancel: []
|
||||
/** Emitted when user selects a version, so parent can fetch full version data with changelog */
|
||||
versionSelect: [version: Labrinth.Versions.v2.Version]
|
||||
versionHover: [version: Labrinth.Versions.v2.Version]
|
||||
/** Emitted when user clicks "Find compatible version" in simplified warning mode */
|
||||
searchCompat: []
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const incompatibleUpdateModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||
const searchQuery = ref('')
|
||||
const hideIncompatibleState = ref(true)
|
||||
const switchMode = ref(false)
|
||||
const pendingIncompatibleUpdate = ref<{
|
||||
version: Labrinth.Versions.v2.Version
|
||||
event: MouseEvent
|
||||
} | null>(null)
|
||||
const suppressCancelOnHide = ref(false)
|
||||
|
||||
const { selectedVersion, pinnedInitialVersionId, selectVersion, resetInitialSelection } =
|
||||
useContentUpdaterSelection({
|
||||
versions: toRef(props, 'versions'),
|
||||
currentVersionId: toRef(props, 'currentVersionId'),
|
||||
onVersionSelect: (version) => emit('versionSelect', version),
|
||||
debug,
|
||||
})
|
||||
|
||||
function isVersionCompatible(version: Labrinth.Versions.v2.Version): boolean {
|
||||
const compatible = versionMatchesCompatibilityTarget(version, {
|
||||
gameVersion: props.currentGameVersion,
|
||||
loader: props.currentLoader,
|
||||
projectType: props.projectType,
|
||||
})
|
||||
|
||||
if (!compatible) {
|
||||
debug('isVersionCompatible: INCOMPATIBLE', {
|
||||
versionId: version.id,
|
||||
versionNumber: version.version_number,
|
||||
versionLoaders: version.loaders,
|
||||
versionGameVersions: version.game_versions,
|
||||
currentLoader: props.currentLoader,
|
||||
currentGameVersion: props.currentGameVersion,
|
||||
projectType: props.projectType,
|
||||
})
|
||||
}
|
||||
return compatible
|
||||
}
|
||||
|
||||
const currentVersion = computed(() => props.versions.find((v) => v.id === props.currentVersionId))
|
||||
const showUpdateWarning = computed(() => !isModpack.value)
|
||||
|
||||
const isDowngrade = computed(() => {
|
||||
if (!selectedVersion.value || !currentVersion.value) return false
|
||||
return (
|
||||
new Date(selectedVersion.value.date_published) < new Date(currentVersion.value.date_published)
|
||||
)
|
||||
})
|
||||
|
||||
const filteredVersions = useContentUpdaterFiltering({
|
||||
versions: toRef(props, 'versions'),
|
||||
searchQuery,
|
||||
isModpack,
|
||||
incompatibilityWarningMode,
|
||||
hideIncompatibleState,
|
||||
selectedVersion,
|
||||
pinnedInitialVersionId,
|
||||
currentVersionId: toRef(props, 'currentVersionId'),
|
||||
isVersionCompatible,
|
||||
debug,
|
||||
})
|
||||
|
||||
function shouldShowBadge(version: Labrinth.Versions.v2.Version): boolean {
|
||||
if (incompatibilityWarningMode.value) return false
|
||||
return version.id === props.currentVersionId || shouldShowIncompatibleBadge(version)
|
||||
}
|
||||
|
||||
function shouldShowIncompatibleBadge(version: Labrinth.Versions.v2.Version): boolean {
|
||||
return version.id !== props.currentVersionId && !isModpack.value && !isVersionCompatible(version)
|
||||
}
|
||||
|
||||
function getBadgeLabel(version: Labrinth.Versions.v2.Version): string {
|
||||
if (version.id === props.currentVersionId) return formatMessage(messages.currentBadge)
|
||||
if (shouldShowIncompatibleBadge(version)) return formatMessage(messages.incompatibleBadge)
|
||||
return ''
|
||||
}
|
||||
|
||||
function getBadgeClasses(version: Labrinth.Versions.v2.Version): string {
|
||||
// Current badge
|
||||
if (version.id === props.currentVersionId) {
|
||||
return 'bg-surface-4 border-surface-5 text-primary'
|
||||
}
|
||||
|
||||
if (shouldShowIncompatibleBadge(version)) {
|
||||
return 'bg-highlight-orange border-brand-orange text-brand-orange'
|
||||
}
|
||||
|
||||
// Version type badges
|
||||
switch (version.version_type) {
|
||||
case 'release':
|
||||
return 'bg-highlight-green border-brand text-brand'
|
||||
case 'beta':
|
||||
return 'bg-highlight-blue border-brand-blue text-brand-blue'
|
||||
case 'alpha':
|
||||
return 'bg-highlight-purple border-brand-purple text-brand-purple'
|
||||
default:
|
||||
return 'bg-surface-4 border-surface-5 text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function getVersionTypeBadgeClasses(version: Labrinth.Versions.v2.Version): string {
|
||||
switch (version.version_type) {
|
||||
case 'release':
|
||||
return 'bg-highlight-green border-brand text-brand'
|
||||
case 'beta':
|
||||
return 'bg-highlight-blue border-brand-blue text-brand-blue'
|
||||
case 'alpha':
|
||||
return 'bg-highlight-purple border-brand-purple text-brand-purple'
|
||||
default:
|
||||
return 'bg-surface-4 border-surface-5 text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function formatLongDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatLoaderGameVersion(version: Labrinth.Versions.v2.Version): string {
|
||||
const loader = capitalizeString(version.loaders[0] || '')
|
||||
const gameVersions = formatGameVersions(version)
|
||||
return [loader, gameVersions].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function formatGameVersions(version: Labrinth.Versions.v2.Version): string {
|
||||
if (!incompatibilityWarningMode.value) {
|
||||
return version.game_versions[0] || ''
|
||||
}
|
||||
|
||||
const gameVersions = tags?.gameVersions.value?.length
|
||||
? formatVersionsForDisplay(version.game_versions, tags.gameVersions.value as GameVersionTag[])
|
||||
: version.game_versions
|
||||
|
||||
return gameVersions.join(', ')
|
||||
}
|
||||
|
||||
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
|
||||
const HOVER_DURATION_TO_PREFETCH_MS = 500
|
||||
function handleVersionMouseEnter(version: Labrinth.Versions.v2.Version) {
|
||||
prefetchTimeout = useTimeoutFn(
|
||||
() => emit('versionHover', version),
|
||||
HOVER_DURATION_TO_PREFETCH_MS,
|
||||
{ immediate: false },
|
||||
)
|
||||
prefetchTimeout.start()
|
||||
}
|
||||
|
||||
function handleVersionMouseLeave() {
|
||||
if (prefetchTimeout) prefetchTimeout.stop()
|
||||
}
|
||||
|
||||
function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
if (prefetchTimeout) prefetchTimeout.stop()
|
||||
selectVersion(version)
|
||||
}
|
||||
|
||||
function handleUpdate(event: MouseEvent) {
|
||||
if (props.actionLoading || props.actionDisabled) return
|
||||
if (showSimplifiedWarning.value) {
|
||||
emit('update', undefined as unknown as Labrinth.Versions.v2.Version, event)
|
||||
hide()
|
||||
return
|
||||
}
|
||||
if (selectedVersion.value) {
|
||||
if (incompatibilityWarningMode.value) {
|
||||
emitUpdate(selectedVersion.value, event, { hide: false })
|
||||
return
|
||||
}
|
||||
|
||||
const changesGameVersion = versionChangesGameVersion(
|
||||
selectedVersion.value,
|
||||
props.currentGameVersion,
|
||||
)
|
||||
const shouldShowParentWarning =
|
||||
isModpack.value && !event.shiftKey && (changesGameVersion || isDowngrade.value)
|
||||
if (
|
||||
isModpack.value &&
|
||||
!event.shiftKey &&
|
||||
!isVersionCompatible(selectedVersion.value) &&
|
||||
!changesGameVersion
|
||||
) {
|
||||
pendingIncompatibleUpdate.value = {
|
||||
version: selectedVersion.value,
|
||||
event,
|
||||
}
|
||||
incompatibleUpdateModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
emitUpdate(selectedVersion.value, event, {
|
||||
hide: !shouldShowParentWarning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function confirmIncompatibleUpdate() {
|
||||
const pendingUpdate = pendingIncompatibleUpdate.value
|
||||
pendingIncompatibleUpdate.value = null
|
||||
|
||||
if (pendingUpdate) {
|
||||
const current = currentVersion.value
|
||||
const isPendingDowngrade = current
|
||||
? new Date(pendingUpdate.version.date_published) < new Date(current.date_published)
|
||||
: false
|
||||
const changesGameVersion = versionChangesGameVersion(
|
||||
pendingUpdate.version,
|
||||
props.currentGameVersion,
|
||||
)
|
||||
const shouldShowParentWarning =
|
||||
isModpack.value && !pendingUpdate.event.shiftKey && (changesGameVersion || isPendingDowngrade)
|
||||
|
||||
emitUpdate(pendingUpdate.version, pendingUpdate.event, {
|
||||
hide: !shouldShowParentWarning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function emitUpdate(
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
event: MouseEvent,
|
||||
options: { hide?: boolean } = {},
|
||||
) {
|
||||
emit('update', version, event)
|
||||
if (options.hide ?? true) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleSearchCompat() {
|
||||
emit('searchCompat')
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleModalHide() {
|
||||
if (suppressCancelOnHide.value) {
|
||||
suppressCancelOnHide.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function show(initialVersionId?: string, options?: { switchMode?: boolean }) {
|
||||
searchQuery.value = ''
|
||||
hideIncompatibleState.value = incompatibilityWarningMode.value ? false : !isModpack.value
|
||||
pendingIncompatibleUpdate.value = null
|
||||
switchMode.value = options?.switchMode ?? false
|
||||
|
||||
debug('show() called', {
|
||||
initialVersionId,
|
||||
currentVersionId: props.currentVersionId,
|
||||
currentGameVersion: props.currentGameVersion,
|
||||
currentLoader: props.currentLoader,
|
||||
projectType: props.projectType,
|
||||
versionsAvailable: props.versions.length,
|
||||
})
|
||||
|
||||
if (props.versions.length > 0) {
|
||||
const currentInList = props.versions.find((v) => v.id === props.currentVersionId)
|
||||
debug('show(): currentVersionId lookup', {
|
||||
currentVersionId: props.currentVersionId,
|
||||
foundInList: !!currentInList,
|
||||
allVersionIds: props.versions.map((v) => v.id),
|
||||
})
|
||||
}
|
||||
|
||||
resetInitialSelection(initialVersionId)
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
suppressCancelOnHide.value = true
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@ -0,0 +1,68 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type UseContentUpdaterFilteringOptions = {
|
||||
versions: Readonly<Ref<Labrinth.Versions.v2.Version[]>>
|
||||
searchQuery: Ref<string>
|
||||
isModpack: ComputedRef<boolean>
|
||||
incompatibilityWarningMode: ComputedRef<boolean>
|
||||
hideIncompatibleState: Ref<boolean>
|
||||
selectedVersion: Ref<Labrinth.Versions.v2.Version | null>
|
||||
pinnedInitialVersionId: Ref<string | undefined>
|
||||
currentVersionId: Readonly<Ref<string>>
|
||||
isVersionCompatible: (version: Labrinth.Versions.v2.Version) => boolean
|
||||
debug: (message: string, data?: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export function useContentUpdaterFiltering({
|
||||
versions: sourceVersions,
|
||||
searchQuery,
|
||||
isModpack,
|
||||
incompatibilityWarningMode,
|
||||
hideIncompatibleState,
|
||||
selectedVersion,
|
||||
pinnedInitialVersionId,
|
||||
currentVersionId,
|
||||
isVersionCompatible,
|
||||
debug,
|
||||
}: UseContentUpdaterFilteringOptions) {
|
||||
return computed(() => {
|
||||
let versions = [...sourceVersions.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
versions = versions.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(query) ||
|
||||
v.version_number.toLowerCase().includes(query) ||
|
||||
(incompatibilityWarningMode.value &&
|
||||
[...v.loaders, ...v.game_versions].some((value) =>
|
||||
value.toLowerCase().includes(query),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
const beforeFilterCount = versions.length
|
||||
if (!incompatibilityWarningMode.value && !isModpack.value && hideIncompatibleState.value) {
|
||||
versions = versions.filter(
|
||||
(version) =>
|
||||
version.id === currentVersionId.value ||
|
||||
version.id === selectedVersion.value?.id ||
|
||||
version.id === pinnedInitialVersionId.value ||
|
||||
isVersionCompatible(version),
|
||||
)
|
||||
}
|
||||
|
||||
debug('filteredVersions computed', {
|
||||
totalVersions: sourceVersions.value.length,
|
||||
afterSearchFilter: beforeFilterCount,
|
||||
afterCompatibilityFilter: versions.length,
|
||||
hiddenByCompatibility: beforeFilterCount - versions.length,
|
||||
hideIncompatible: hideIncompatibleState.value,
|
||||
filteringCompatibility: !isModpack.value && hideIncompatibleState.value,
|
||||
})
|
||||
|
||||
return versions
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
type UseContentUpdaterSelectionOptions = {
|
||||
versions: Readonly<Ref<Labrinth.Versions.v2.Version[]>>
|
||||
currentVersionId: Readonly<Ref<string>>
|
||||
onVersionSelect: (version: Labrinth.Versions.v2.Version) => void
|
||||
debug: (message: string, data?: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export function useContentUpdaterSelection({
|
||||
versions,
|
||||
currentVersionId,
|
||||
onVersionSelect,
|
||||
debug,
|
||||
}: UseContentUpdaterSelectionOptions) {
|
||||
const selectedVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const pendingInitialVersionId = ref<string | undefined>(undefined)
|
||||
const pinnedInitialVersionId = ref<string | undefined>(undefined)
|
||||
|
||||
watch(
|
||||
versions,
|
||||
(newVersions) => {
|
||||
if (selectedVersion.value) {
|
||||
const updatedVersion = newVersions.find((v) => v.id === selectedVersion.value?.id)
|
||||
if (updatedVersion && updatedVersion !== selectedVersion.value) {
|
||||
selectedVersion.value = updatedVersion
|
||||
}
|
||||
}
|
||||
|
||||
if (newVersions.length > 0 && !selectedVersion.value && pendingInitialVersionId.value) {
|
||||
const pendingFound = newVersions.find((v) => v.id === pendingInitialVersionId.value)
|
||||
debug('versions watcher: initial selection', {
|
||||
pendingInitialVersionId: pendingInitialVersionId.value,
|
||||
foundPending: !!pendingFound,
|
||||
currentVersionId: currentVersionId.value,
|
||||
currentInList: newVersions.some((v) => v.id === currentVersionId.value),
|
||||
totalVersions: newVersions.length,
|
||||
loaderDistribution: [...new Set(newVersions.flatMap((v) => v.loaders))],
|
||||
gameVersionDistribution: [...new Set(newVersions.flatMap((v) => v.game_versions))].slice(
|
||||
0,
|
||||
10,
|
||||
),
|
||||
})
|
||||
const version = pendingFound ?? newVersions[0]
|
||||
selectedVersion.value = version
|
||||
if (version) {
|
||||
onVersionSelect(version)
|
||||
}
|
||||
pendingInitialVersionId.value = undefined
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function selectVersion(version: Labrinth.Versions.v2.Version) {
|
||||
selectedVersion.value = version
|
||||
onVersionSelect(version)
|
||||
}
|
||||
|
||||
function resetInitialSelection(initialVersionId?: string) {
|
||||
pinnedInitialVersionId.value = initialVersionId
|
||||
|
||||
if (versions.value.length > 0) {
|
||||
selectedVersion.value = initialVersionId
|
||||
? (versions.value.find((v) => v.id === initialVersionId) ?? versions.value[0])
|
||||
: versions.value[0]
|
||||
pendingInitialVersionId.value = undefined
|
||||
if (selectedVersion.value) {
|
||||
onVersionSelect(selectedVersion.value)
|
||||
}
|
||||
} else {
|
||||
selectedVersion.value = null
|
||||
pendingInitialVersionId.value = initialVersionId
|
||||
debug('show(): no versions yet, deferring selection', {
|
||||
pendingInitialVersionId: initialVersionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedVersion,
|
||||
pinnedInitialVersionId,
|
||||
selectVersion,
|
||||
resetInitialSelection,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
export type BulkOperationType = 'enable' | 'disable' | 'delete' | 'update'
|
||||
|
||||
export function useBulkOperation() {
|
||||
const isBulkOperating = ref(false)
|
||||
const bulkProgress = ref(0)
|
||||
const bulkTotal = ref(0)
|
||||
const bulkOperation = ref<BulkOperationType | null>(null)
|
||||
const bulkWaiting = ref(false)
|
||||
|
||||
function resetState() {
|
||||
isBulkOperating.value = false
|
||||
bulkOperation.value = null
|
||||
bulkProgress.value = 0
|
||||
bulkTotal.value = 0
|
||||
bulkWaiting.value = false
|
||||
}
|
||||
|
||||
async function runBulk<T>(
|
||||
operation: BulkOperationType,
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
options?: { delayMs?: number; onComplete?: () => void },
|
||||
) {
|
||||
const delayMs = options?.delayMs ?? 250
|
||||
isBulkOperating.value = true
|
||||
bulkOperation.value = operation
|
||||
bulkTotal.value = items.length
|
||||
bulkProgress.value = 0
|
||||
|
||||
try {
|
||||
for (const item of items) {
|
||||
await fn(item)
|
||||
bulkProgress.value++
|
||||
if (delayMs > 0 && bulkProgress.value < items.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
options?.onComplete?.()
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
|
||||
async function runBulkWithWaiting(
|
||||
operation: BulkOperationType,
|
||||
total: number,
|
||||
fn: () => Promise<void>,
|
||||
onComplete?: () => void,
|
||||
) {
|
||||
isBulkOperating.value = true
|
||||
bulkOperation.value = operation
|
||||
bulkTotal.value = total
|
||||
bulkProgress.value = 0
|
||||
bulkWaiting.value = true
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
onComplete?.()
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (isBulkOperating.value) {
|
||||
e.preventDefault()
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
watch(isBulkOperating, (operating) => {
|
||||
if (operating) {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (isBulkOperating.value) {
|
||||
return window.confirm('A bulk operation is in progress. Are you sure you want to leave?')
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isBulkOperating,
|
||||
bulkProgress,
|
||||
bulkTotal,
|
||||
bulkOperation,
|
||||
bulkWaiting,
|
||||
runBulk,
|
||||
runBulkWithWaiting,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useChangingItems() {
|
||||
const changingItems = ref(new Set<string>())
|
||||
|
||||
function markChanging(id: string) {
|
||||
changingItems.value = new Set([...changingItems.value, id])
|
||||
}
|
||||
|
||||
function unmarkChanging(id: string) {
|
||||
const next = new Set(changingItems.value)
|
||||
next.delete(id)
|
||||
changingItems.value = next
|
||||
}
|
||||
|
||||
function isChanging(id: string): boolean {
|
||||
return changingItems.value.has(id)
|
||||
}
|
||||
|
||||
return { changingItems, markChanging, unmarkChanging, isChanging }
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
pruneContentFilterSelections,
|
||||
pruneMetadataFilterSelections,
|
||||
} from './content-filter-state.ts'
|
||||
|
||||
test('keeps content filters while options are still loading', () => {
|
||||
assert.deepEqual(
|
||||
pruneContentFilterSelections(
|
||||
{ typeFilters: ['mod'], statusFilters: ['disabled'] },
|
||||
{ type: [], status: [] },
|
||||
false,
|
||||
),
|
||||
{ typeFilters: ['mod'], statusFilters: ['disabled'] },
|
||||
)
|
||||
})
|
||||
|
||||
test('prunes content filters only after the option set is ready', () => {
|
||||
assert.deepEqual(
|
||||
pruneContentFilterSelections(
|
||||
{ typeFilters: ['mod', 'shader'], statusFilters: ['disabled', 'updates'] },
|
||||
{ type: ['mod'], status: ['disabled'] },
|
||||
true,
|
||||
),
|
||||
{ typeFilters: ['mod'], statusFilters: ['disabled'] },
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps valid filters when the current search has no matches', () => {
|
||||
assert.deepEqual(
|
||||
pruneContentFilterSelections(
|
||||
{ typeFilters: ['mod'], statusFilters: ['disabled'] },
|
||||
{ type: ['mod', 'shader'], status: ['enabled', 'disabled'] },
|
||||
true,
|
||||
),
|
||||
{ typeFilters: ['mod'], statusFilters: ['disabled'] },
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps the duplicate filter when sorting changes the available type options', () => {
|
||||
assert.deepEqual(
|
||||
pruneContentFilterSelections(
|
||||
{ typeFilters: ['duplicates'], statusFilters: [] },
|
||||
{ type: ['mod', 'duplicates'], status: [] },
|
||||
true,
|
||||
),
|
||||
{ typeFilters: ['duplicates'], statusFilters: [] },
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps metadata exclusions through an empty loading state', () => {
|
||||
const selections = { state: ['enabled'], loader: ['forge'] }
|
||||
assert.deepEqual(pruneMetadataFilterSelections(selections, [], false), selections)
|
||||
assert.deepEqual(
|
||||
pruneMetadataFilterSelections(
|
||||
selections,
|
||||
[
|
||||
{ key: 'state', options: [{ value: 'enabled' }, { value: 'disabled' }] },
|
||||
{ key: 'loader', options: [{ value: 'fabric' }] },
|
||||
],
|
||||
true,
|
||||
),
|
||||
{ state: ['enabled'] },
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps a metadata exclusion that remains valid but is not displayed as a filter option', () => {
|
||||
assert.deepEqual(
|
||||
pruneMetadataFilterSelections(
|
||||
{ state: ['disabled'] },
|
||||
[{ key: 'state', options: [{ value: 'disabled' }] }],
|
||||
true,
|
||||
),
|
||||
{ state: ['disabled'] },
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,52 @@
|
||||
export interface ContentFilterSelections {
|
||||
typeFilters: string[]
|
||||
statusFilters: string[]
|
||||
}
|
||||
|
||||
export interface MetadataFilterOptions {
|
||||
key: string
|
||||
options: Array<{ value: string }>
|
||||
}
|
||||
|
||||
function cloneMetadataExcluded(excluded: Record<string, string[]>): Record<string, string[]> {
|
||||
return Object.fromEntries(Object.entries(excluded).map(([key, values]) => [key, [...values]]))
|
||||
}
|
||||
|
||||
export function pruneContentFilterSelections(
|
||||
selections: ContentFilterSelections,
|
||||
options: { type: string[]; status: string[] },
|
||||
optionsReady: boolean,
|
||||
): ContentFilterSelections {
|
||||
if (!optionsReady) {
|
||||
return {
|
||||
typeFilters: [...selections.typeFilters],
|
||||
statusFilters: [...selections.statusFilters],
|
||||
}
|
||||
}
|
||||
|
||||
const typeOptions = new Set(options.type)
|
||||
const statusOptions = new Set(options.status)
|
||||
return {
|
||||
typeFilters: selections.typeFilters.filter((filter) => typeOptions.has(filter)),
|
||||
statusFilters: selections.statusFilters.filter((filter) => statusOptions.has(filter)),
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneMetadataFilterSelections(
|
||||
excluded: Record<string, string[]>,
|
||||
categories: MetadataFilterOptions[],
|
||||
optionsReady: boolean,
|
||||
): Record<string, string[]> {
|
||||
if (!optionsReady) return cloneMetadataExcluded(excluded)
|
||||
|
||||
const categoriesByKey = new Map(categories.map((category) => [category.key, category]))
|
||||
const next: Record<string, string[]> = {}
|
||||
for (const [key, values] of Object.entries(excluded)) {
|
||||
const category = categoriesByKey.get(key)
|
||||
if (!category) continue
|
||||
const validValues = new Set(category.options.map((option) => option.value))
|
||||
const retained = values.filter((value) => validValues.has(value))
|
||||
if (retained.length > 0) next[key] = retained
|
||||
}
|
||||
return next
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import type { ClientWarningType, ContentItem } from '../types'
|
||||
|
||||
const CLIENT_ONLY_ENVIRONMENTS = new Set(['client_only', 'singleplayer_only'])
|
||||
|
||||
export function isClientOnlyEnvironment(env?: string | null): boolean {
|
||||
return !!env && CLIENT_ONLY_ENVIRONMENTS.has(env)
|
||||
}
|
||||
|
||||
export function getClientWarningType(item: ContentItem): ClientWarningType | null {
|
||||
if (item.pack_client_retained) return 'retained'
|
||||
if (item.pack_client_depends) return 'depends'
|
||||
if (isClientOnlyEnvironment(item.environment)) return 'environment'
|
||||
return null
|
||||
}
|
||||
|
||||
export function isPresentContentItem(item: ContentItem): boolean {
|
||||
return (
|
||||
item.instanceMaterializationState == null || item.instanceMaterializationState === 'present'
|
||||
)
|
||||
}
|
||||
|
||||
export function isEnabledContentItem(item: ContentItem): boolean {
|
||||
return isPresentContentItem(item) && item.enabled === true
|
||||
}
|
||||
|
||||
export function isDisabledContentItem(item: ContentItem): boolean {
|
||||
return isPresentContentItem(item) && item.enabled === false
|
||||
}
|
||||
|
||||
export function canToggleContentItem(item: ContentItem): boolean {
|
||||
return (
|
||||
isPresentContentItem(item) &&
|
||||
item.enabled !== undefined &&
|
||||
item.instanceCapabilities?.canToggle !== false
|
||||
)
|
||||
}
|
||||
|
||||
export interface ContentFilterOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
@ -0,0 +1,514 @@
|
||||
import Fuse from 'fuse.js'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonProjectTypeCategoryMessages, normalizeProjectType } from '#ui/utils/common-messages'
|
||||
|
||||
import type { ContentItem } from '../types'
|
||||
import { type ContentFilterSelections, pruneContentFilterSelections } from './content-filter-state'
|
||||
import type { ContentFilterOption } from './content-filtering'
|
||||
import {
|
||||
getClientWarningType,
|
||||
isDisabledContentItem,
|
||||
isEnabledContentItem,
|
||||
} from './content-filtering'
|
||||
|
||||
// Re-export utility functions and types for convenience
|
||||
export type { ContentFilterOption } from './content-filtering'
|
||||
export { getClientWarningType, isClientOnlyEnvironment } from './content-filtering'
|
||||
|
||||
// ---- window 级内存持久化(导航切换保留,关软件丢弃) ----
|
||||
|
||||
const memory: Record<string, Map<string, unknown>> = ((
|
||||
window as unknown as { __ctMemory?: Record<string, Map<string, unknown>> }
|
||||
).__ctMemory ??= {})
|
||||
function getMap<K, V>(namespace: string): Map<K, V> {
|
||||
if (!memory[namespace]) memory[namespace] = new Map<string, unknown>()
|
||||
return memory[namespace] as Map<K, V>
|
||||
}
|
||||
|
||||
// ---- types ----
|
||||
|
||||
export interface ContentPipelineConfig {
|
||||
items: Ref<ContentItem[]>
|
||||
modpackItems?: Ref<ContentItem[] | undefined>
|
||||
duplicateItems?: Ref<ContentItem[] | undefined>
|
||||
sortItems: (items: ContentItem[]) => ContentItem[]
|
||||
getItemId: (item: ContentItem) => string
|
||||
showTypeFilters?: boolean
|
||||
showUpdateFilter?: boolean
|
||||
showWarningsFilter?: boolean
|
||||
isPackLocked?: Ref<boolean>
|
||||
memoryKey?: string
|
||||
searchKeys?: string[]
|
||||
initialFilters?: ContentFilterSelections
|
||||
filterOptionsReady?: Ref<boolean> | ComputedRef<boolean>
|
||||
}
|
||||
|
||||
interface PipelineResult {
|
||||
filteredItems: ContentItem[]
|
||||
filteredModpackItems: ContentItem[]
|
||||
filterCounts: Record<string, number>
|
||||
row1FilterOptions: ContentFilterOption[]
|
||||
row2FilterOptions: ContentFilterOption[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
// ---- messages ----
|
||||
|
||||
const filterMessages = defineMessages({
|
||||
updates: {
|
||||
id: 'content.filter.updates',
|
||||
defaultMessage: 'Update available',
|
||||
},
|
||||
warnings: {
|
||||
id: 'content.filter.warnings',
|
||||
defaultMessage: 'Warnings',
|
||||
},
|
||||
duplicates: {
|
||||
id: 'content.filter.duplicates',
|
||||
defaultMessage: 'Duplicates',
|
||||
},
|
||||
enabled: {
|
||||
id: 'content.filter.enabled',
|
||||
defaultMessage: 'Enabled',
|
||||
},
|
||||
disabled: {
|
||||
id: 'content.filter.disabled',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
})
|
||||
|
||||
// ---- composable ----
|
||||
|
||||
export function useContentPipeline(config: ContentPipelineConfig) {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const {
|
||||
items,
|
||||
modpackItems,
|
||||
duplicateItems,
|
||||
sortItems,
|
||||
getItemId,
|
||||
showTypeFilters = false,
|
||||
showUpdateFilter = false,
|
||||
showWarningsFilter = false,
|
||||
memoryKey = '',
|
||||
searchKeys = ['project.title', 'owner.name', 'file_name'],
|
||||
initialFilters,
|
||||
filterOptionsReady,
|
||||
} = config
|
||||
|
||||
// ---- filter state ----
|
||||
|
||||
function normalizeTypeFilters(value: string | string[] | null | undefined): string[] {
|
||||
if (!value) return []
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
const filterMemory = getMap<string, { type: string[]; status: string[] }>('filter')
|
||||
const savedFilters = memoryKey ? filterMemory.get(memoryKey) : undefined
|
||||
const selectedTypeFilter = ref<string[]>(
|
||||
normalizeTypeFilters(initialFilters?.typeFilters ?? savedFilters?.type),
|
||||
)
|
||||
const selectedStatusFilters = ref<string[]>(
|
||||
initialFilters?.statusFilters ?? savedFilters?.status ?? [],
|
||||
)
|
||||
watch(
|
||||
() => memoryKey,
|
||||
(key) => {
|
||||
if (initialFilters) return
|
||||
if (key) {
|
||||
const entry = filterMemory.get(key)
|
||||
selectedTypeFilter.value = normalizeTypeFilters(entry?.type)
|
||||
selectedStatusFilters.value = entry?.status ?? []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([selectedTypeFilter, selectedStatusFilters], () => {
|
||||
if (memoryKey) {
|
||||
filterMemory.set(memoryKey, {
|
||||
type: [...selectedTypeFilter.value],
|
||||
status: [...selectedStatusFilters.value],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ---- search state ----
|
||||
|
||||
const searchMemory = getMap<string, string>('search')
|
||||
const searchKey = memoryKey ? `${memoryKey}:search` : ''
|
||||
const searchQuery = ref(searchKey ? (searchMemory.get(searchKey) ?? '') : '')
|
||||
|
||||
watch(searchQuery, (val) => {
|
||||
if (searchKey) searchMemory.set(searchKey, val)
|
||||
})
|
||||
|
||||
// ---- Fuse instance ----
|
||||
|
||||
const fuse = new Fuse<ContentItem>([], {
|
||||
keys: searchKeys,
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
})
|
||||
|
||||
// ---- sorted items (computed because they only depend on items + sortMode) ----
|
||||
|
||||
const sortedItems = computed(() => sortItems(items.value))
|
||||
|
||||
const modpackItemsNoUpdate = computed(() => {
|
||||
const raw = modpackItems?.value ?? []
|
||||
return sortItems(
|
||||
raw.map((item) => ({
|
||||
...item,
|
||||
update: null,
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
const filterValidationOptions = computed(() => {
|
||||
const type = new Set<string>()
|
||||
const status = new Set<string>()
|
||||
const allItems = [...modpackItemsNoUpdate.value, ...sortedItems.value]
|
||||
const duplicateItemIds = new Set((duplicateItems?.value ?? []).map((item) => getItemId(item)))
|
||||
|
||||
for (const item of allItems) {
|
||||
type.add(normalizeProjectType(item.project_type))
|
||||
if (showUpdateFilter && item.update != null) status.add('updates')
|
||||
if (showWarningsFilter && getClientWarningType(item) !== null) status.add('warnings')
|
||||
if (isEnabledContentItem(item)) status.add('enabled')
|
||||
if (isDisabledContentItem(item)) status.add('disabled')
|
||||
}
|
||||
if (allItems.some((item) => duplicateItemIds.has(getItemId(item)))) {
|
||||
type.add('duplicates')
|
||||
}
|
||||
|
||||
return {
|
||||
type: [...type],
|
||||
status: [...status],
|
||||
}
|
||||
})
|
||||
|
||||
const modpackChildIdSet = computed(() => {
|
||||
return new Set(
|
||||
(modpackItems?.value ?? []).map((item) => getItemId(item).replace(/\.disabled$/, '')),
|
||||
)
|
||||
})
|
||||
|
||||
const searchableItemCount = computed(() => {
|
||||
const modpackList = modpackItems?.value ?? []
|
||||
const regularItems = items.value.filter((item) => !modpackChildIdSet.value.has(getItemId(item)))
|
||||
return modpackList.length + regularItems.length
|
||||
})
|
||||
|
||||
// ---- single-pass pipeline result ----
|
||||
|
||||
const result = shallowRef<PipelineResult>({
|
||||
filteredItems: [],
|
||||
filteredModpackItems: [],
|
||||
filterCounts: {},
|
||||
row1FilterOptions: [],
|
||||
row2FilterOptions: [],
|
||||
totalCount: 0,
|
||||
})
|
||||
|
||||
let pipelineTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function runPipeline(): PipelineResult {
|
||||
const query = searchQuery.value.trim()
|
||||
const typeFilters = selectedTypeFilter.value
|
||||
const statusFilters = selectedStatusFilters.value
|
||||
|
||||
// Step 1: Fuse search once (old code calls fuse.search 3 times, we call once)
|
||||
let fuseResults: ContentItem[] | null = null
|
||||
if (query) {
|
||||
fuseResults = fuse.search(query).map((r) => r.item)
|
||||
}
|
||||
|
||||
// Helper: replicate old search(source) behavior:
|
||||
// - no query: return source as-is
|
||||
// - with query: return all Fuse results (source parameter is ignored)
|
||||
function search(source: ContentItem[]): ContentItem[] {
|
||||
if (!query) return source
|
||||
return fuseResults!
|
||||
}
|
||||
|
||||
// Step 2: Compute searchedAllItems (for filter UI — counts, options, totalCount)
|
||||
// Old code: [...modpackSearched.filter(modpackChildIdSet), ...regularSearched.filter(!modpackChildIdSet)]
|
||||
const modpackChildIds = modpackChildIdSet.value
|
||||
const modpackSearched = search(modpackItemsNoUpdate.value).filter((item) =>
|
||||
modpackChildIds.has(getItemId(item).replace(/\.disabled$/, '')),
|
||||
)
|
||||
const regularSearched = search(sortedItems.value).filter(
|
||||
(item) => !modpackChildIds.has(getItemId(item).replace(/\.disabled$/, '')),
|
||||
)
|
||||
const searchedAllItems = [...modpackSearched, ...regularSearched]
|
||||
const duplicateItemIds = new Set((duplicateItems?.value ?? []).map((item) => getItemId(item)))
|
||||
const matchesTypeFilter = (item: ContentItem) =>
|
||||
typeFilters.includes(normalizeProjectType(item.project_type)) ||
|
||||
(typeFilters.includes('duplicates') && duplicateItemIds.has(getItemId(item)))
|
||||
|
||||
// Step 3: Compute typeFilteredItems and statusFilteredItems from searchedAllItems
|
||||
const typeFiltered: ContentItem[] =
|
||||
typeFilters.length > 0 ? searchedAllItems.filter(matchesTypeFilter) : searchedAllItems
|
||||
const hasEnabled = typeFiltered.some(isEnabledContentItem)
|
||||
const hasDisabled = typeFiltered.some(isDisabledContentItem)
|
||||
const availableStatusFilters = new Set<string>()
|
||||
if (showUpdateFilter && typeFiltered.some((item) => item.update != null)) {
|
||||
availableStatusFilters.add('updates')
|
||||
}
|
||||
if (showWarningsFilter && typeFiltered.some((item) => getClientWarningType(item) !== null)) {
|
||||
availableStatusFilters.add('warnings')
|
||||
}
|
||||
if (hasEnabled) availableStatusFilters.add('enabled')
|
||||
if (hasDisabled) availableStatusFilters.add('disabled')
|
||||
const effectiveStatusFilters = statusFilters.filter((filter) =>
|
||||
availableStatusFilters.has(filter),
|
||||
)
|
||||
|
||||
let statusFiltered = searchedAllItems
|
||||
if (effectiveStatusFilters.length > 0) {
|
||||
statusFiltered = searchedAllItems.filter((item) => {
|
||||
for (const f of effectiveStatusFilters) {
|
||||
if (f === 'updates' && item.update == null) return false
|
||||
if (f === 'enabled' && !isEnabledContentItem(item)) return false
|
||||
if (f === 'disabled' && !isDisabledContentItem(item)) return false
|
||||
if (f === 'warnings' && getClientWarningType(item) === null) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Step 4: Compute filterCounts (matching old semantics)
|
||||
const counts: Record<string, number> = {}
|
||||
|
||||
// type counts: from statusFiltered (status-filtered items, NOT type-filtered)
|
||||
for (const item of statusFiltered) {
|
||||
const type = normalizeProjectType(item.project_type)
|
||||
counts[type] = (counts[type] || 0) + 1
|
||||
}
|
||||
counts['duplicates'] = statusFiltered.filter((item) =>
|
||||
duplicateItemIds.has(getItemId(item)),
|
||||
).length
|
||||
|
||||
// status counts: from typeFiltered (type-filtered items, NOT status-filtered)
|
||||
counts['updates'] = typeFiltered.filter((m) => m.update != null).length
|
||||
counts['enabled'] = typeFiltered.filter(isEnabledContentItem).length
|
||||
counts['disabled'] = typeFiltered.filter(isDisabledContentItem).length
|
||||
counts['warnings'] = typeFiltered.filter((m) => getClientWarningType(m) !== null).length
|
||||
|
||||
// totalCount: from statusFiltered (same as old code)
|
||||
const totalCount = statusFiltered.length
|
||||
|
||||
// Step 5: Build row1FilterOptions from searchedAllItems (ALL items, like old code)
|
||||
const row1: ContentFilterOption[] = []
|
||||
if (showTypeFilters) {
|
||||
const frequency: Record<string, number> = {}
|
||||
for (const item of searchedAllItems) {
|
||||
const normalized = normalizeProjectType(item.project_type)
|
||||
frequency[normalized] = (frequency[normalized] || 0) + 1
|
||||
}
|
||||
const types = Object.keys(frequency).sort((a, b) => frequency[b] - frequency[a])
|
||||
for (const type of types) {
|
||||
const msg =
|
||||
commonProjectTypeCategoryMessages[type as keyof typeof commonProjectTypeCategoryMessages]
|
||||
const label = msg ? formatMessage(msg) : type.charAt(0).toUpperCase() + type.slice(1) + 's'
|
||||
row1.push({ id: type, label })
|
||||
}
|
||||
if (searchedAllItems.some((item) => duplicateItemIds.has(getItemId(item)))) {
|
||||
row1.push({ id: 'duplicates', label: formatMessage(filterMessages.duplicates) })
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Build row2FilterOptions from typeFiltered (same as old code)
|
||||
const row2: ContentFilterOption[] = []
|
||||
if (showUpdateFilter && typeFiltered.some((m) => m.update != null)) {
|
||||
row2.push({ id: 'updates', label: formatMessage(filterMessages.updates) })
|
||||
}
|
||||
if (showWarningsFilter && typeFiltered.some((m) => getClientWarningType(m) !== null)) {
|
||||
row2.push({ id: 'warnings', label: formatMessage(filterMessages.warnings) })
|
||||
}
|
||||
|
||||
if (hasEnabled && hasDisabled) {
|
||||
row2.push({ id: 'enabled', label: formatMessage(filterMessages.enabled) })
|
||||
row2.push({ id: 'disabled', label: formatMessage(filterMessages.disabled) })
|
||||
}
|
||||
|
||||
// Step 7: Compute filteredItems and filteredModpackItems (matching old layout.vue)
|
||||
// Old filteredItems = applyFilters(search(sortedItems))
|
||||
// Old filteredModpackItems = applyFilters(search(modpackItemsNoUpdate).filter(modpackIds))
|
||||
function applyFilters(source: ContentItem[]): ContentItem[] {
|
||||
let result = source
|
||||
if (typeFilters.length > 0) {
|
||||
result = result.filter(matchesTypeFilter)
|
||||
}
|
||||
if (effectiveStatusFilters.length > 0) {
|
||||
result = result.filter((item) => {
|
||||
for (const f of effectiveStatusFilters) {
|
||||
if (f === 'updates' && item.update == null) return false
|
||||
if (f === 'enabled' && !isEnabledContentItem(item)) return false
|
||||
if (f === 'disabled' && !isDisabledContentItem(item)) return false
|
||||
if (f === 'warnings' && getClientWarningType(item) === null) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const filteredItems = applyFilters(search(sortedItems.value))
|
||||
|
||||
const modpackIds = new Set(modpackItemsNoUpdate.value.map((item) => getItemId(item)))
|
||||
const filteredModpackItems =
|
||||
modpackItemsNoUpdate.value.length === 0
|
||||
? []
|
||||
: applyFilters(
|
||||
search(modpackItemsNoUpdate.value).filter((item) => modpackIds.has(getItemId(item))),
|
||||
)
|
||||
|
||||
return {
|
||||
filteredItems,
|
||||
filteredModpackItems,
|
||||
filterCounts: counts,
|
||||
row1FilterOptions: row1,
|
||||
row2FilterOptions: row2,
|
||||
totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger pipeline when any dependency changes (debounced)
|
||||
watch(
|
||||
[
|
||||
sortedItems,
|
||||
modpackItemsNoUpdate,
|
||||
duplicateItems,
|
||||
searchQuery,
|
||||
selectedTypeFilter,
|
||||
selectedStatusFilters,
|
||||
],
|
||||
() => {
|
||||
if (pipelineTimer) clearTimeout(pipelineTimer)
|
||||
pipelineTimer = setTimeout(() => {
|
||||
result.value = runPipeline()
|
||||
}, 100)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Update Fuse index asynchronously when items change (separate from pipeline)
|
||||
watch(
|
||||
() => [sortedItems.value, modpackItemsNoUpdate.value] as const,
|
||||
([sorted, modpack]) => {
|
||||
const seenIds = new Set<string>()
|
||||
const collection: ContentItem[] = []
|
||||
|
||||
for (const item of sorted) {
|
||||
const id = getItemId(item)
|
||||
if (!seenIds.has(id)) {
|
||||
seenIds.add(id)
|
||||
collection.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of modpack) {
|
||||
const id = getItemId(item)
|
||||
if (!seenIds.has(id)) {
|
||||
seenIds.add(id)
|
||||
collection.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Use setTimeout to avoid blocking the main thread
|
||||
setTimeout(() => {
|
||||
fuse.setCollection(collection)
|
||||
}, 0)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// ---- filter API (compatible with old interface) ----
|
||||
|
||||
const filteredItems = computed(() => result.value.filteredItems)
|
||||
const filteredModpackItems = computed(() => result.value.filteredModpackItems)
|
||||
const filterCounts = computed(() => result.value.filterCounts)
|
||||
const row1FilterOptions = computed(() => result.value.row1FilterOptions)
|
||||
const row2FilterOptions = computed(() => result.value.row2FilterOptions)
|
||||
const totalCount = computed(() => result.value.totalCount)
|
||||
|
||||
// Clean up invalid selections when options change
|
||||
watch(
|
||||
[filterValidationOptions, () => filterOptionsReady?.value ?? true],
|
||||
() => {
|
||||
const pruned = pruneContentFilterSelections(
|
||||
{
|
||||
typeFilters: selectedTypeFilter.value,
|
||||
statusFilters: selectedStatusFilters.value,
|
||||
},
|
||||
filterValidationOptions.value,
|
||||
filterOptionsReady?.value ?? true,
|
||||
)
|
||||
if (pruned.typeFilters.length !== selectedTypeFilter.value.length) {
|
||||
selectedTypeFilter.value = pruned.typeFilters
|
||||
}
|
||||
if (pruned.statusFilters.length !== selectedStatusFilters.value.length) {
|
||||
selectedStatusFilters.value = pruned.statusFilters
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggleTypeFilter(filterId: string, event?: MouseEvent | KeyboardEvent) {
|
||||
if (event?.ctrlKey || event?.metaKey) {
|
||||
selectedTypeFilter.value = selectedTypeFilter.value.includes(filterId)
|
||||
? selectedTypeFilter.value.filter((id) => id !== filterId)
|
||||
: [...selectedTypeFilter.value, filterId]
|
||||
return
|
||||
}
|
||||
|
||||
selectedTypeFilter.value = [filterId]
|
||||
}
|
||||
|
||||
function toggleStatusFilter(filterId: string) {
|
||||
if (filterId === 'enabled' || filterId === 'disabled') {
|
||||
const index = selectedStatusFilters.value.indexOf(filterId)
|
||||
const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
|
||||
if (index === -1) {
|
||||
selectedStatusFilters.value = [
|
||||
...selectedStatusFilters.value.filter((f) => f !== otherStatusFilter),
|
||||
filterId,
|
||||
]
|
||||
} else {
|
||||
selectedStatusFilters.value.splice(index, 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const index = selectedStatusFilters.value.indexOf(filterId)
|
||||
if (index === -1) {
|
||||
selectedStatusFilters.value.push(filterId)
|
||||
} else {
|
||||
selectedStatusFilters.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
searchableItemCount,
|
||||
sortedItems,
|
||||
modpackItemsNoUpdate,
|
||||
modpackChildIdSet,
|
||||
selectedTypeFilter,
|
||||
selectedStatusFilters,
|
||||
row1FilterOptions,
|
||||
row2FilterOptions,
|
||||
totalCount,
|
||||
filterCounts,
|
||||
filteredItems,
|
||||
filteredModpackItems,
|
||||
toggleTypeFilter,
|
||||
toggleStatusFilter,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { ContentItem } from '../types'
|
||||
|
||||
export function useContentSelection(
|
||||
items: Ref<ContentItem[]>,
|
||||
getItemId: (item: ContentItem) => string,
|
||||
) {
|
||||
const selectedIds = ref<string[]>([])
|
||||
|
||||
const selectedItems = computed(() => {
|
||||
const selectedIdSet = new Set(selectedIds.value)
|
||||
const seenIds = new Set<string>()
|
||||
const result: ContentItem[] = []
|
||||
|
||||
for (const item of items.value) {
|
||||
const id = getItemId(item)
|
||||
if (selectedIdSet.has(id) && !seenIds.has(id)) {
|
||||
seenIds.add(id)
|
||||
result.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
watch(
|
||||
() => items.value.map(getItemId),
|
||||
(newIds) => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
const validIds = new Set(newIds)
|
||||
const pruned = selectedIds.value.filter((id) => validIds.has(id))
|
||||
if (pruned.length !== selectedIds.value.length) {
|
||||
selectedIds.value = pruned
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function removeFromSelection(id: string) {
|
||||
selectedIds.value = selectedIds.value.filter((i) => i !== id)
|
||||
}
|
||||
|
||||
return { selectedIds, selectedItems, clearSelection, removeFromSelection }
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { ContentItem } from '../types.ts'
|
||||
import { sortContentItems } from './content-sorting.ts'
|
||||
|
||||
function item(id: string, fileName: string, projectName?: string, dateAdded?: string): ContentItem {
|
||||
return {
|
||||
id,
|
||||
file_name: fileName,
|
||||
project_type: 'mod',
|
||||
update: null,
|
||||
origin_provider: null,
|
||||
date_added: dateAdded,
|
||||
project: projectName
|
||||
? {
|
||||
id,
|
||||
slug: id,
|
||||
title: projectName,
|
||||
icon_url: null,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
test('sorts file names naturally and treats disabled suffix as state', () => {
|
||||
const sorted = sortContentItems(
|
||||
[
|
||||
item('ten', '中文模组-10.jar'),
|
||||
item('disabled', '中文模组-2.jar.disabled'),
|
||||
item('two', '中文模组-2.jar'),
|
||||
],
|
||||
'file-name-asc',
|
||||
'zh-CN',
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((entry) => entry.id),
|
||||
['two', 'disabled', 'ten'],
|
||||
)
|
||||
})
|
||||
|
||||
test('uses file names and stable ids to break project-name ties', () => {
|
||||
const sorted = sortContentItems(
|
||||
[
|
||||
item('shared-hash', 'zeta.jar', 'Same project'),
|
||||
item('shared-hash', 'alpha.jar', 'Same project', 'entry-b'),
|
||||
item('shared-hash', 'alpha.jar', 'Same project', 'entry-a'),
|
||||
],
|
||||
'project-name-asc',
|
||||
'en-US',
|
||||
(entry) => entry.date_added ?? 'entry-c',
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((entry) => entry.date_added),
|
||||
['entry-a', 'entry-b', undefined],
|
||||
)
|
||||
})
|
||||
|
||||
test('sorts added dates in both directions with deterministic missing-date fallback', () => {
|
||||
const items = [
|
||||
item('missing', 'missing.jar', undefined),
|
||||
item('older', 'older.jar', undefined, '2026-01-01T00:00:00Z'),
|
||||
item('newer', 'newer.jar', undefined, '2026-02-01T00:00:00Z'),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
sortContentItems(items, 'date-added-newest').map((entry) => entry.id),
|
||||
['newer', 'older', 'missing'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
sortContentItems(items, 'date-added-oldest').map((entry) => entry.id),
|
||||
['missing', 'older', 'newer'],
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,82 @@
|
||||
import type { ContentItem } from '../types'
|
||||
|
||||
export type ContentSortMode =
|
||||
| 'project-name-asc'
|
||||
| 'project-name-desc'
|
||||
| 'file-name-asc'
|
||||
| 'file-name-desc'
|
||||
| 'date-added-newest'
|
||||
| 'date-added-oldest'
|
||||
|
||||
function fileNameSortKey(fileName: string): string {
|
||||
return fileName.replace(/\.disabled$/i, '')
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string, locale?: string): number {
|
||||
return left.localeCompare(right, locale, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
}
|
||||
|
||||
function compareByFileName(
|
||||
left: ContentItem,
|
||||
right: ContentItem,
|
||||
locale?: string,
|
||||
getStableId: (item: ContentItem) => string = (item) => item.id,
|
||||
): number {
|
||||
return (
|
||||
compareText(fileNameSortKey(left.file_name), fileNameSortKey(right.file_name), locale) ||
|
||||
compareText(left.file_name, right.file_name, locale) ||
|
||||
compareText(left.project?.title ?? '', right.project?.title ?? '', locale) ||
|
||||
compareText(getStableId(left), getStableId(right), locale)
|
||||
)
|
||||
}
|
||||
|
||||
function compareByProjectName(
|
||||
left: ContentItem,
|
||||
right: ContentItem,
|
||||
locale?: string,
|
||||
getStableId?: (item: ContentItem) => string,
|
||||
): number {
|
||||
return (
|
||||
compareText(
|
||||
left.project?.title ?? left.file_name,
|
||||
right.project?.title ?? right.file_name,
|
||||
locale,
|
||||
) || compareByFileName(left, right, locale, getStableId)
|
||||
)
|
||||
}
|
||||
|
||||
export function sortContentItems(
|
||||
items: ContentItem[],
|
||||
mode: ContentSortMode,
|
||||
locale?: string,
|
||||
getStableId?: (item: ContentItem) => string,
|
||||
): ContentItem[] {
|
||||
const sorted = [...items]
|
||||
|
||||
return sorted.sort((left, right) => {
|
||||
switch (mode) {
|
||||
case 'project-name-desc':
|
||||
return -compareByProjectName(left, right, locale, getStableId)
|
||||
case 'file-name-asc':
|
||||
return compareByFileName(left, right, locale, getStableId)
|
||||
case 'file-name-desc':
|
||||
return -compareByFileName(left, right, locale, getStableId)
|
||||
case 'date-added-newest':
|
||||
return (
|
||||
(right.date_added ?? '').localeCompare(left.date_added ?? '') ||
|
||||
compareByFileName(left, right, locale, getStableId)
|
||||
)
|
||||
case 'date-added-oldest':
|
||||
return (
|
||||
(left.date_added ?? '').localeCompare(right.date_added ?? '') ||
|
||||
compareByFileName(left, right, locale, getStableId)
|
||||
)
|
||||
case 'project-name-asc':
|
||||
default:
|
||||
return compareByProjectName(left, right, locale, getStableId)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
clearPinnedContentViewPreferences,
|
||||
getPinnedContentViewPreferences,
|
||||
setPinnedContentViewPreferences,
|
||||
} from './content-view-state.ts'
|
||||
|
||||
const originalStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
|
||||
|
||||
function installMemoryStorage() {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
})
|
||||
return values
|
||||
}
|
||||
|
||||
function restoreStorage() {
|
||||
if (originalStorageDescriptor) {
|
||||
Object.defineProperty(globalThis, 'localStorage', originalStorageDescriptor)
|
||||
} else {
|
||||
delete (globalThis as { localStorage?: Storage }).localStorage
|
||||
}
|
||||
}
|
||||
|
||||
test('keeps pinned content views isolated by instance', () => {
|
||||
installMemoryStorage()
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
setPinnedContentViewPreferences('instance-a', {
|
||||
sortMode: 'file-name-asc',
|
||||
typeFilters: ['mod'],
|
||||
statusFilters: ['disabled'],
|
||||
metadataExcluded: { state: ['enabled'] },
|
||||
}),
|
||||
true,
|
||||
)
|
||||
setPinnedContentViewPreferences('instance-b', {
|
||||
sortMode: 'date-added-newest',
|
||||
typeFilters: ['shader'],
|
||||
statusFilters: [],
|
||||
metadataExcluded: {},
|
||||
})
|
||||
|
||||
assert.deepEqual(getPinnedContentViewPreferences('instance-a'), {
|
||||
version: 1,
|
||||
sortMode: 'file-name-asc',
|
||||
typeFilters: ['mod'],
|
||||
statusFilters: ['disabled'],
|
||||
metadataExcluded: { state: ['enabled'] },
|
||||
})
|
||||
assert.equal(getPinnedContentViewPreferences('missing'), null)
|
||||
|
||||
clearPinnedContentViewPreferences('instance-a')
|
||||
assert.equal(getPinnedContentViewPreferences('instance-a'), null)
|
||||
} finally {
|
||||
restoreStorage()
|
||||
}
|
||||
})
|
||||
|
||||
test('ignores malformed or unsupported pinned preferences', () => {
|
||||
const values = installMemoryStorage()
|
||||
|
||||
try {
|
||||
values.set('axolotl-content-view-preferences-v1:broken', '{invalid json')
|
||||
assert.equal(getPinnedContentViewPreferences('broken'), null)
|
||||
|
||||
values.set(
|
||||
'axolotl-content-view-preferences-v1:unsupported',
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
sortMode: 'file-name-asc',
|
||||
typeFilters: [],
|
||||
statusFilters: [],
|
||||
metadataExcluded: {},
|
||||
}),
|
||||
)
|
||||
assert.equal(getPinnedContentViewPreferences('unsupported'), null)
|
||||
} finally {
|
||||
restoreStorage()
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,127 @@
|
||||
import type { ContentSortMode } from './content-sorting'
|
||||
|
||||
export interface ContentViewFilters {
|
||||
typeFilters: string[]
|
||||
statusFilters: string[]
|
||||
metadataExcluded: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface ContentViewState extends ContentViewFilters {
|
||||
sortMode: ContentSortMode
|
||||
searchQuery: string
|
||||
metadataFilterExpanded: boolean
|
||||
expandedGroups: string[]
|
||||
scrollTop: number
|
||||
anchorId?: string
|
||||
anchorOffset?: number
|
||||
}
|
||||
|
||||
export interface PinnedContentViewPreferencesV1 extends ContentViewFilters {
|
||||
version: 1
|
||||
sortMode: ContentSortMode
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'axolotl-content-view-preferences-v1:'
|
||||
|
||||
const sortModes = new Set<ContentSortMode>([
|
||||
'project-name-asc',
|
||||
'project-name-desc',
|
||||
'file-name-asc',
|
||||
'file-name-desc',
|
||||
'date-added-newest',
|
||||
'date-added-oldest',
|
||||
])
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
||||
}
|
||||
|
||||
function isMetadataExcluded(value: unknown): value is Record<string, string[]> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
return Object.values(value).every(isStringArray)
|
||||
}
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
return typeof localStorage !== 'undefined'
|
||||
}
|
||||
|
||||
function storageKey(instanceId: string): string {
|
||||
return `${STORAGE_PREFIX}${instanceId}`
|
||||
}
|
||||
|
||||
export function cloneContentViewFilters(filters: ContentViewFilters): ContentViewFilters {
|
||||
return {
|
||||
typeFilters: [...filters.typeFilters],
|
||||
statusFilters: [...filters.statusFilters],
|
||||
metadataExcluded: Object.fromEntries(
|
||||
Object.entries(filters.metadataExcluded).map(([key, values]) => [key, [...values]]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function getPinnedContentViewPreferences(
|
||||
instanceId: string,
|
||||
): PinnedContentViewPreferencesV1 | null {
|
||||
if (!canUseStorage()) return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(instanceId))
|
||||
if (!raw) return null
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
const value = parsed as Partial<PinnedContentViewPreferencesV1>
|
||||
if (
|
||||
value.version !== 1 ||
|
||||
!value.sortMode ||
|
||||
!sortModes.has(value.sortMode) ||
|
||||
!isStringArray(value.typeFilters) ||
|
||||
!isStringArray(value.statusFilters) ||
|
||||
!isMetadataExcluded(value.metadataExcluded)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
sortMode: value.sortMode,
|
||||
...cloneContentViewFilters({
|
||||
typeFilters: value.typeFilters,
|
||||
statusFilters: value.statusFilters,
|
||||
metadataExcluded: value.metadataExcluded,
|
||||
}),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setPinnedContentViewPreferences(
|
||||
instanceId: string,
|
||||
preferences: Omit<PinnedContentViewPreferencesV1, 'version'>,
|
||||
): boolean {
|
||||
if (!canUseStorage()) return false
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKey(instanceId),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sortMode: preferences.sortMode,
|
||||
...cloneContentViewFilters(preferences),
|
||||
}),
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPinnedContentViewPreferences(instanceId: string): void {
|
||||
if (!canUseStorage()) return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(storageKey(instanceId))
|
||||
} catch {
|
||||
// Storage failures must not block content management.
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ContentCardTableItem } from '../types'
|
||||
|
||||
export interface UseGroupSelectionOptions {
|
||||
items: Ref<ContentCardTableItem[]>
|
||||
selectedIds: Ref<string[]>
|
||||
}
|
||||
|
||||
export interface GroupCheckboxState {
|
||||
checked: boolean
|
||||
indeterminate: boolean
|
||||
}
|
||||
|
||||
export function useGroupSelection(options: UseGroupSelectionOptions) {
|
||||
const { items, selectedIds } = options
|
||||
|
||||
const allSelected = computed(() => {
|
||||
if (items.value.length === 0) return false
|
||||
return items.value.every((item) => selectedIds.value.includes(item.id))
|
||||
})
|
||||
|
||||
const someSelected = computed(() => {
|
||||
return items.value.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
|
||||
})
|
||||
|
||||
function getGroupCheckboxState(item: ContentCardTableItem): GroupCheckboxState {
|
||||
if (!item.isGroupHeader || !item.groupChildIds) {
|
||||
return { checked: false, indeterminate: false }
|
||||
}
|
||||
if (item.groupChildIds.length === 0) {
|
||||
return { checked: false, indeterminate: false }
|
||||
}
|
||||
const selectedCount = item.groupChildIds.filter((id) => selectedIds.value.includes(id)).length
|
||||
if (selectedCount === item.groupChildIds.length) {
|
||||
return { checked: true, indeterminate: false }
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
return { checked: false, indeterminate: true }
|
||||
}
|
||||
return { checked: false, indeterminate: false }
|
||||
}
|
||||
|
||||
function isItemSelected(itemId: string): boolean {
|
||||
return selectedIds.value.includes(itemId)
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value || someSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
const ids = new Set<string>()
|
||||
for (const item of items.value) {
|
||||
ids.add(item.id)
|
||||
if (item.isGroupHeader && item.groupChildIds) {
|
||||
for (const childId of item.groupChildIds) {
|
||||
ids.add(childId)
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedIds.value = [...ids]
|
||||
}
|
||||
}
|
||||
|
||||
function toggleItemSelection(
|
||||
itemId: string,
|
||||
selected: boolean,
|
||||
lastSelectedIndex: Ref<number | null>,
|
||||
index?: number,
|
||||
event?: MouseEvent,
|
||||
item?: ContentCardTableItem,
|
||||
) {
|
||||
if (selected && event?.shiftKey && lastSelectedIndex.value !== null && index !== undefined) {
|
||||
const start = Math.min(lastSelectedIndex.value, index)
|
||||
const end = Math.max(lastSelectedIndex.value, index)
|
||||
const rangeIds = items.value.slice(start, end + 1).map((item) => item.id)
|
||||
const merged = new Set([...selectedIds.value, ...rangeIds])
|
||||
selectedIds.value = [...merged]
|
||||
} else if (selected) {
|
||||
if (!selectedIds.value.includes(itemId)) {
|
||||
selectedIds.value = [...selectedIds.value, itemId]
|
||||
}
|
||||
} else {
|
||||
selectedIds.value = selectedIds.value.filter((id) => id !== itemId)
|
||||
}
|
||||
|
||||
if (item?.isGroupHeader && item.groupChildIds) {
|
||||
if (selected) {
|
||||
const merged = new Set([...selectedIds.value, ...item.groupChildIds])
|
||||
selectedIds.value = [...merged]
|
||||
} else {
|
||||
const childIds = new Set(item.groupChildIds)
|
||||
selectedIds.value = selectedIds.value.filter((id) => !childIds.has(id))
|
||||
}
|
||||
}
|
||||
|
||||
if (index !== undefined) {
|
||||
lastSelectedIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allSelected,
|
||||
someSelected,
|
||||
getGroupCheckboxState,
|
||||
isItemSelected,
|
||||
toggleSelectAll,
|
||||
toggleItemSelection,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
export * from './bulk-operations'
|
||||
export * from './changing-items'
|
||||
export * from './content-filter-state'
|
||||
export * from './content-filtering'
|
||||
export * from './content-pipeline'
|
||||
export * from './content-selection'
|
||||
export * from './content-sorting'
|
||||
export * from './content-view-state'
|
||||
export * from './group-selection'
|
||||
export * from './use-content-folder-groups'
|
||||
export * from './use-content-metadata-filters'
|
||||
export * from './use-horizontal-filter-scroll'
|
||||
@ -0,0 +1,146 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { buildFileTreeRows, collectFileTreeFolders, type FileTreeEntry } from '#ui/utils/file-tree'
|
||||
|
||||
import type { ContentCardTableItem, ContentItem } from '../types'
|
||||
|
||||
export interface ContentFolderGroupPolicy {
|
||||
/** Whether an item participates in folder grouping. */
|
||||
isGroupedItem: (item: ContentItem) => boolean
|
||||
/** Folder-tree relative path used to build groups for a grouped item. */
|
||||
treePath: (item: ContentItem) => string
|
||||
/** Stable group id for a folder path, kept distinct from other group ids. */
|
||||
folderGroupId: (path: string) => string
|
||||
/** Prefix(es) used to recognize this policy's group ids in the shared expanded set. */
|
||||
folderGroupIdPrefix: string | string[]
|
||||
}
|
||||
|
||||
export interface UseContentFolderGroupsOptions extends ContentFolderGroupPolicy {
|
||||
filteredItems: ComputedRef<ContentItem[]>
|
||||
modpackChildIdSet: ComputedRef<Set<string>>
|
||||
searchQuery: Ref<string>
|
||||
expandedGroups: Ref<Set<string>>
|
||||
persistExpandedGroups: (groups: Set<string>) => void
|
||||
getItemId: (item: ContentItem) => string
|
||||
mapToTableItem: (item: ContentItem, group?: string) => ContentCardTableItem
|
||||
locale: Ref<string>
|
||||
}
|
||||
|
||||
type GroupedItem = FileTreeEntry & { item: ContentItem }
|
||||
|
||||
/**
|
||||
* Renders items that share a folder path as collapsible group rows while
|
||||
* keeping everything else flat. New folders are expanded the first time they
|
||||
* appear so freshly added content stays visible; expansion state is shared
|
||||
* through the caller-provided `expandedGroups` set.
|
||||
*/
|
||||
export function useContentFolderGroups(options: UseContentFolderGroupsOptions) {
|
||||
const {
|
||||
filteredItems,
|
||||
modpackChildIdSet,
|
||||
searchQuery,
|
||||
expandedGroups,
|
||||
persistExpandedGroups,
|
||||
getItemId,
|
||||
mapToTableItem,
|
||||
isGroupedItem,
|
||||
treePath,
|
||||
folderGroupId,
|
||||
folderGroupIdPrefix,
|
||||
locale,
|
||||
} = options
|
||||
|
||||
const isModpackChild = (item: ContentItem) =>
|
||||
modpackChildIdSet.value.has(getItemId(item).replace(/\.disabled$/, ''))
|
||||
|
||||
const groupedItems = computed(() =>
|
||||
filteredItems.value.filter((item) => !isModpackChild(item) && isGroupedItem(item)),
|
||||
)
|
||||
|
||||
/** Filtered items that are not rendered by this composable. */
|
||||
const regularItems = computed(() =>
|
||||
filteredItems.value.filter((item) => !isModpackChild(item) && !isGroupedItem(item)),
|
||||
)
|
||||
|
||||
const groupedEntries = computed<GroupedItem[]>(() =>
|
||||
groupedItems.value.map((item) => ({
|
||||
item,
|
||||
id: getItemId(item),
|
||||
relativePath: treePath(item),
|
||||
fileName: item.file_name,
|
||||
})),
|
||||
)
|
||||
|
||||
const groupIdPrefixes = Array.isArray(folderGroupIdPrefix)
|
||||
? folderGroupIdPrefix
|
||||
: [folderGroupIdPrefix]
|
||||
|
||||
const expandedFolderPaths = computed(() => {
|
||||
const paths = new Set<string>()
|
||||
for (const id of expandedGroups.value) {
|
||||
const prefix = groupIdPrefixes.find((candidate) => id.startsWith(candidate))
|
||||
if (prefix) {
|
||||
paths.add(id.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
})
|
||||
|
||||
const seenFolderPaths = ref(new Set<string>())
|
||||
watch(
|
||||
groupedEntries,
|
||||
(entries) => {
|
||||
const folderPaths = collectFileTreeFolders(entries)
|
||||
const newPaths = folderPaths.filter((path) => !seenFolderPaths.value.has(path))
|
||||
if (newPaths.length === 0) return
|
||||
for (const path of newPaths) {
|
||||
seenFolderPaths.value.add(path)
|
||||
}
|
||||
expandedGroups.value = new Set([...expandedGroups.value, ...newPaths.map(folderGroupId)])
|
||||
persistExpandedGroups(expandedGroups.value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** Group headers and their children, or flat rows while searching. */
|
||||
const folderRows = computed<ContentCardTableItem[]>(() => {
|
||||
const entries = groupedEntries.value
|
||||
if (entries.length === 0) return []
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
return entries.map((entry) => mapToTableItem(entry.item))
|
||||
}
|
||||
|
||||
const rows: ContentCardTableItem[] = []
|
||||
for (const row of buildFileTreeRows(entries, expandedFolderPaths.value, '', locale.value)) {
|
||||
if (row.kind === 'folder') {
|
||||
const groupId = folderGroupId(row.path)
|
||||
rows.push({
|
||||
id: groupId,
|
||||
isGroupHeader: true,
|
||||
group: groupId,
|
||||
groupDepth: row.depth,
|
||||
groupItemCount: row.fileCount,
|
||||
groupChildIds: row.childIds,
|
||||
project: {
|
||||
id: groupId,
|
||||
slug: null,
|
||||
title: row.name,
|
||||
icon_url: null,
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
} else {
|
||||
const parentGroup = row.parentPath ? folderGroupId(row.parentPath) : undefined
|
||||
rows.push({
|
||||
...mapToTableItem(row.file.item, parentGroup),
|
||||
...(row.depth > 1 ? { groupDepth: row.depth } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
return { folderRows, regularItems }
|
||||
}
|
||||
@ -0,0 +1,503 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import type { ContentItem } from '../types'
|
||||
import { pruneMetadataFilterSelections } from './content-filter-state'
|
||||
|
||||
export interface MetadataFilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface MetadataFilterCategory {
|
||||
key: string
|
||||
label: string
|
||||
searchable?: boolean
|
||||
options: MetadataFilterOption[]
|
||||
}
|
||||
|
||||
interface MetadataFilterDefinition {
|
||||
key: string
|
||||
label: string
|
||||
searchable?: boolean
|
||||
values: (item: ContentItem) => string[]
|
||||
labelFor: (value: string) => string
|
||||
/** Preferred option order; unlisted values (including 未知) sort after ordered ones. */
|
||||
order?: string[]
|
||||
}
|
||||
|
||||
const UNKNOWN = 'unknown'
|
||||
|
||||
const openSourceLicenseIds = new Set([
|
||||
'0BSD',
|
||||
'AFL-3.0',
|
||||
'AGPL-3.0',
|
||||
'Apache-2.0',
|
||||
'Artistic-2.0',
|
||||
'BSD-2-Clause',
|
||||
'BSD-3-Clause',
|
||||
'BSL-1.0',
|
||||
'CDDL-1.0',
|
||||
'ECL-2.0',
|
||||
'EPL-1.0',
|
||||
'EPL-2.0',
|
||||
'EUPL-1.1',
|
||||
'EUPL-1.2',
|
||||
'GPL-2.0',
|
||||
'GPL-3.0',
|
||||
'ISC',
|
||||
'LGPL-2.1',
|
||||
'LGPL-3.0',
|
||||
'MIT',
|
||||
'MPL-2.0',
|
||||
'NCSA',
|
||||
'OSL-3.0',
|
||||
'PostgreSQL',
|
||||
'Python-2.0',
|
||||
'Unlicense',
|
||||
'UPL-1.0',
|
||||
'Zlib',
|
||||
])
|
||||
|
||||
type EnvironmentFilterValue = 'client' | 'server' | 'client_and_server' | 'singleplayer'
|
||||
|
||||
function getEnvironmentFilterValue(environment?: string): EnvironmentFilterValue | undefined {
|
||||
switch (environment) {
|
||||
case 'client_only':
|
||||
return 'client'
|
||||
case 'server_only':
|
||||
case 'dedicated_server_only':
|
||||
return 'server'
|
||||
case 'client_and_server':
|
||||
case 'client_only_server_optional':
|
||||
case 'server_only_client_optional':
|
||||
case 'client_or_server':
|
||||
case 'client_or_server_prefers_both':
|
||||
return 'client_and_server'
|
||||
case 'singleplayer_only':
|
||||
return 'singleplayer'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenSource(item: ContentItem): boolean {
|
||||
const licenseId = item.project?.license?.id.replace(/-(?:only|or-later)$/, '')
|
||||
return !!licenseId && openSourceLicenseIds.has(licenseId)
|
||||
}
|
||||
|
||||
const loaderKeys = new Set(['fabric', 'forge', 'neoforge', 'quilt'])
|
||||
|
||||
const messages = defineMessages({
|
||||
categoryAuthor: {
|
||||
id: 'content.metadata-filter.author',
|
||||
defaultMessage: 'Author',
|
||||
},
|
||||
categoryEnvironment: {
|
||||
id: 'content.metadata-filter.environment',
|
||||
defaultMessage: 'Environment',
|
||||
},
|
||||
categoryLoader: {
|
||||
id: 'content.metadata-filter.loader',
|
||||
defaultMessage: 'Loader',
|
||||
},
|
||||
categorySource: {
|
||||
id: 'content.metadata-filter.source',
|
||||
defaultMessage: 'Source',
|
||||
},
|
||||
categoryExternal: {
|
||||
id: 'content.metadata-filter.external',
|
||||
defaultMessage: 'External files',
|
||||
},
|
||||
categoryOpenSource: {
|
||||
id: 'content.metadata-filter.open-source',
|
||||
defaultMessage: 'Open source',
|
||||
},
|
||||
optionUnknown: {
|
||||
id: 'content.metadata-filter.unknown',
|
||||
defaultMessage: 'Unknown',
|
||||
},
|
||||
optionClient: {
|
||||
id: 'content.metadata-filter.environment.client',
|
||||
defaultMessage: 'Client',
|
||||
},
|
||||
optionServer: {
|
||||
id: 'content.metadata-filter.environment.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
optionClientAndServer: {
|
||||
id: 'content.metadata-filter.environment.client-and-server',
|
||||
defaultMessage: 'Client & server',
|
||||
},
|
||||
optionSingleplayer: {
|
||||
id: 'content.metadata-filter.environment.singleplayer',
|
||||
defaultMessage: 'Singleplayer',
|
||||
},
|
||||
optionOtherLoader: {
|
||||
id: 'content.metadata-filter.loader.other',
|
||||
defaultMessage: 'Other',
|
||||
},
|
||||
loaderFabric: {
|
||||
id: 'content.metadata-filter.loader.fabric',
|
||||
defaultMessage: 'Fabric',
|
||||
},
|
||||
loaderForge: {
|
||||
id: 'content.metadata-filter.loader.forge',
|
||||
defaultMessage: 'Forge',
|
||||
},
|
||||
loaderNeoForge: {
|
||||
id: 'content.metadata-filter.loader.neoforge',
|
||||
defaultMessage: 'NeoForge',
|
||||
},
|
||||
loaderQuilt: {
|
||||
id: 'content.metadata-filter.loader.quilt',
|
||||
defaultMessage: 'Quilt',
|
||||
},
|
||||
optionSourceLocal: {
|
||||
id: 'content.metadata-filter.source.local',
|
||||
defaultMessage: 'Local',
|
||||
},
|
||||
optionSourceCurseforge: {
|
||||
id: 'content.metadata-filter.source.curseforge',
|
||||
defaultMessage: 'CurseForge',
|
||||
},
|
||||
optionSourceModrinthModpack: {
|
||||
id: 'content.metadata-filter.source.modrinth-modpack',
|
||||
defaultMessage: 'Modrinth modpack',
|
||||
},
|
||||
optionSourceImportedModpack: {
|
||||
id: 'content.metadata-filter.source.imported-modpack',
|
||||
defaultMessage: 'Imported modpack',
|
||||
},
|
||||
optionSourceServerProject: {
|
||||
id: 'content.metadata-filter.source.server-project',
|
||||
defaultMessage: 'Server project',
|
||||
},
|
||||
optionSourceSharedInstance: {
|
||||
id: 'content.metadata-filter.source.shared-instance',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
optionExternal: {
|
||||
id: 'content.metadata-filter.external.external',
|
||||
defaultMessage: 'External file',
|
||||
},
|
||||
optionLinked: {
|
||||
id: 'content.metadata-filter.external.linked',
|
||||
defaultMessage: 'Online project',
|
||||
},
|
||||
optionOpenSource: {
|
||||
id: 'content.metadata-filter.open-source.open',
|
||||
defaultMessage: 'Open source',
|
||||
},
|
||||
optionClosedSource: {
|
||||
id: 'content.metadata-filter.open-source.closed',
|
||||
defaultMessage: 'Closed source',
|
||||
},
|
||||
})
|
||||
|
||||
// ---- window 级内存持久化(导航切换保留,关软件丢弃) ----
|
||||
|
||||
const memory: Record<string, Map<string, unknown>> = ((
|
||||
window as unknown as { __ctMemory?: Record<string, Map<string, unknown>> }
|
||||
).__ctMemory ??= {})
|
||||
function getMap<K, V>(namespace: string): Map<K, V> {
|
||||
if (!memory[namespace]) memory[namespace] = new Map<string, unknown>()
|
||||
return memory[namespace] as Map<K, V>
|
||||
}
|
||||
|
||||
export function useContentMetadataFilters(
|
||||
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>,
|
||||
persistKey?: string,
|
||||
initialExcluded?: Record<string, string[]>,
|
||||
filterOptionsReady?: Ref<boolean> | ComputedRef<boolean>,
|
||||
) {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const definitions = computed<MetadataFilterDefinition[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'author',
|
||||
label: formatMessage(messages.categoryAuthor),
|
||||
searchable: true,
|
||||
values: (item) => (item.owner?.name ? [item.owner.name] : [UNKNOWN]),
|
||||
labelFor: (value) => (value === UNKNOWN ? formatMessage(messages.optionUnknown) : value),
|
||||
},
|
||||
{
|
||||
key: 'environment',
|
||||
label: formatMessage(messages.categoryEnvironment),
|
||||
order: ['client', 'server', 'client_and_server', 'singleplayer'],
|
||||
values: (item) => {
|
||||
const value = getEnvironmentFilterValue(item.environment)
|
||||
return value ? [value] : [UNKNOWN]
|
||||
},
|
||||
labelFor: (value) => {
|
||||
switch (value) {
|
||||
case 'client':
|
||||
return formatMessage(messages.optionClient)
|
||||
case 'server':
|
||||
return formatMessage(messages.optionServer)
|
||||
case 'client_and_server':
|
||||
return formatMessage(messages.optionClientAndServer)
|
||||
case 'singleplayer':
|
||||
return formatMessage(messages.optionSingleplayer)
|
||||
default:
|
||||
return formatMessage(messages.optionUnknown)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'loader',
|
||||
label: formatMessage(messages.categoryLoader),
|
||||
order: ['fabric', 'forge', 'neoforge', 'quilt'],
|
||||
values: (item) => {
|
||||
if (!item.loader) return [UNKNOWN]
|
||||
return loaderKeys.has(item.loader) ? [item.loader] : ['other']
|
||||
},
|
||||
labelFor: (value) => {
|
||||
switch (value) {
|
||||
case 'fabric':
|
||||
return formatMessage(messages.loaderFabric)
|
||||
case 'forge':
|
||||
return formatMessage(messages.loaderForge)
|
||||
case 'neoforge':
|
||||
return formatMessage(messages.loaderNeoForge)
|
||||
case 'quilt':
|
||||
return formatMessage(messages.loaderQuilt)
|
||||
case 'other':
|
||||
return formatMessage(messages.optionOtherLoader)
|
||||
default:
|
||||
return formatMessage(messages.optionUnknown)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: formatMessage(messages.categorySource),
|
||||
order: [
|
||||
'local',
|
||||
'curseforge',
|
||||
'modrinth_modpack',
|
||||
'imported_modpack',
|
||||
'server_project',
|
||||
'shared_instance',
|
||||
],
|
||||
values: (item) => {
|
||||
const kind = item.source_kind === 'world_datapack' ? 'local' : item.source_kind
|
||||
return kind ? [kind] : [UNKNOWN]
|
||||
},
|
||||
labelFor: (value) => {
|
||||
switch (value) {
|
||||
case 'local':
|
||||
return formatMessage(messages.optionSourceLocal)
|
||||
case 'curseforge':
|
||||
return formatMessage(messages.optionSourceCurseforge)
|
||||
case 'modrinth_modpack':
|
||||
return formatMessage(messages.optionSourceModrinthModpack)
|
||||
case 'imported_modpack':
|
||||
return formatMessage(messages.optionSourceImportedModpack)
|
||||
case 'server_project':
|
||||
return formatMessage(messages.optionSourceServerProject)
|
||||
case 'shared_instance':
|
||||
return formatMessage(messages.optionSourceSharedInstance)
|
||||
case UNKNOWN:
|
||||
return formatMessage(messages.optionUnknown)
|
||||
default:
|
||||
// 未登记的新来源值:显示可读的原始值,避免与真正的"未知"选项重复
|
||||
return value.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'external',
|
||||
label: formatMessage(messages.categoryExternal),
|
||||
order: ['linked', 'external'],
|
||||
values: (item) => [item.external ? 'external' : 'linked'],
|
||||
labelFor: (value) =>
|
||||
value === 'external'
|
||||
? formatMessage(messages.optionExternal)
|
||||
: formatMessage(messages.optionLinked),
|
||||
},
|
||||
{
|
||||
key: 'open_source',
|
||||
label: formatMessage(messages.categoryOpenSource),
|
||||
order: ['open', 'closed'],
|
||||
values: (item) => {
|
||||
if (isOpenSource(item)) return ['open']
|
||||
return item.project?.license ? ['closed'] : [UNKNOWN]
|
||||
},
|
||||
labelFor: (value) => {
|
||||
switch (value) {
|
||||
case 'open':
|
||||
return formatMessage(messages.optionOpenSource)
|
||||
case 'closed':
|
||||
return formatMessage(messages.optionClosedSource)
|
||||
default:
|
||||
return formatMessage(messages.optionUnknown)
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
const metadataFilterCategories = computed<MetadataFilterCategory[]>(() =>
|
||||
definitions.value
|
||||
.map((definition) => {
|
||||
const options = new Map<string, MetadataFilterOption>()
|
||||
const counts = new Map<string, number>()
|
||||
for (const item of items.value) {
|
||||
const seen = new Set<string>()
|
||||
for (const value of definition.values(item)) {
|
||||
if (seen.has(value)) continue
|
||||
seen.add(value)
|
||||
if (!options.has(value)) {
|
||||
options.set(value, {
|
||||
value,
|
||||
label: definition.labelFor(value),
|
||||
})
|
||||
}
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const total = items.value.length
|
||||
const visible = [...options.values()]
|
||||
.filter((option) => (counts.get(option.value) ?? 0) !== total)
|
||||
.sort((a, b) => {
|
||||
if (a.value === UNKNOWN) return 1
|
||||
if (b.value === UNKNOWN) return -1
|
||||
const order = definition.order
|
||||
if (order) {
|
||||
const indexA = order.indexOf(a.value)
|
||||
const indexB = order.indexOf(b.value)
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB
|
||||
}
|
||||
if (indexA !== -1) return -1
|
||||
if (indexB !== -1) return 1
|
||||
}
|
||||
return a.label.localeCompare(b.label, undefined, {
|
||||
numeric: true,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
searchable: definition.searchable,
|
||||
options: visible,
|
||||
}
|
||||
})
|
||||
.filter((category) => category.options.length > 0),
|
||||
)
|
||||
const metadataFilterValidationOptions = computed<MetadataFilterCategory[]>(() =>
|
||||
definitions.value
|
||||
.map((definition) => {
|
||||
const values = new Set<string>()
|
||||
for (const item of items.value) {
|
||||
for (const value of definition.values(item)) values.add(value)
|
||||
}
|
||||
return {
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
options: [...values].map((value) => ({ value, label: definition.labelFor(value) })),
|
||||
}
|
||||
})
|
||||
.filter((category) => category.options.length > 0),
|
||||
)
|
||||
|
||||
// ---- 选择状态(排除式:勾选 = 显示,取消勾选 = 隐藏;默认全部勾选) ----
|
||||
|
||||
const memory = getMap<string, Record<string, string[]>>('metadataFilters')
|
||||
const excluded = ref<Record<string, string[]>>(
|
||||
initialExcluded ?? (persistKey ? (memory.get(persistKey) ?? {}) : {}),
|
||||
)
|
||||
|
||||
function optionsByKey(key: string): MetadataFilterOption[] {
|
||||
return metadataFilterCategories.value.find((category) => category.key === key)?.options ?? []
|
||||
}
|
||||
|
||||
function getExcludedSet(key: string): Set<string> {
|
||||
return new Set(excluded.value[key] ?? [])
|
||||
}
|
||||
|
||||
function getSelectedValues(key: string): string[] {
|
||||
const excludedSet = getExcludedSet(key)
|
||||
return optionsByKey(key)
|
||||
.filter((option) => !excludedSet.has(option.value))
|
||||
.map((option) => option.value)
|
||||
}
|
||||
|
||||
function setCategorySelection(key: string, selectedValues: string[]) {
|
||||
const selectedSet = new Set(selectedValues)
|
||||
const nextExcluded = optionsByKey(key)
|
||||
.filter((option) => !selectedSet.has(option.value))
|
||||
.map((option) => option.value)
|
||||
if (nextExcluded.length === 0) {
|
||||
const { [key]: _removed, ...rest } = excluded.value
|
||||
excluded.value = rest
|
||||
} else {
|
||||
excluded.value = { ...excluded.value, [key]: nextExcluded }
|
||||
}
|
||||
}
|
||||
|
||||
function setExcludedValues(nextExcluded: Record<string, string[]>) {
|
||||
excluded.value = Object.fromEntries(
|
||||
Object.entries(nextExcluded).map(([key, values]) => [key, [...values]]),
|
||||
)
|
||||
}
|
||||
|
||||
function isCategoryFiltering(key: string): boolean {
|
||||
const options = optionsByKey(key)
|
||||
if (options.length === 0) return false
|
||||
const excludedSet = getExcludedSet(key)
|
||||
return excludedSet.size > 0
|
||||
}
|
||||
|
||||
// 选项变化时修剪失效的排除值(选项消失 → 自动从排除集移除)。
|
||||
watch(
|
||||
[metadataFilterValidationOptions, () => filterOptionsReady?.value ?? true],
|
||||
([categories]) => {
|
||||
const next = pruneMetadataFilterSelections(
|
||||
excluded.value,
|
||||
categories,
|
||||
filterOptionsReady?.value ?? true,
|
||||
)
|
||||
if (JSON.stringify(next) !== JSON.stringify(excluded.value)) excluded.value = next
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
excluded,
|
||||
(value) => {
|
||||
if (persistKey) memory.set(persistKey, value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function applyMetadataFilters(source: ContentItem[]): ContentItem[] {
|
||||
const active = definitions.value.filter((definition) => isCategoryFiltering(definition.key))
|
||||
if (active.length === 0) return source
|
||||
|
||||
return source.filter((item) =>
|
||||
active.every((definition) => {
|
||||
const options = optionsByKey(definition.key)
|
||||
const excludedSet = getExcludedSet(definition.key)
|
||||
// 该分类所有选项都被取消勾选 → 没有任何允许值 → 任何条目都不满足该分类
|
||||
if (excludedSet.size === options.length) return false
|
||||
return definition.values(item).some((value) => !excludedSet.has(value))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
metadataFilterCategories,
|
||||
excluded,
|
||||
getSelectedValues,
|
||||
setCategorySelection,
|
||||
setExcludedValues,
|
||||
isCategoryFiltering,
|
||||
applyMetadataFilters,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
import { onBeforeUnmount, type Ref, ref, watch } from 'vue'
|
||||
|
||||
export interface HorizontalFilterScroll {
|
||||
suppressHoverOpen: Ref<boolean>
|
||||
handleScroll: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a horizontally scrollable filter strip:
|
||||
* - converts vertical wheel input into smooth horizontal scrolling,
|
||||
* - briefly suppresses hover-open dropdowns while the strip is scrolling,
|
||||
* - renders a small custom scrollbar thumb that only appears on hover.
|
||||
*/
|
||||
export function useHorizontalFilterScroll(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
thumbRef: Ref<HTMLElement | null>,
|
||||
): HorizontalFilterScroll {
|
||||
const suppressHoverOpen = ref(false)
|
||||
let filterScrollWheelHandler: ((event: WheelEvent) => void) | null = null
|
||||
let filterHoverSuppressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let scrollTarget: number | null = null
|
||||
let scrollAnimationFrame: number | null = null
|
||||
let scrollbarObserver: ResizeObserver | null = null
|
||||
|
||||
function animateFilterScroll() {
|
||||
const container = containerRef.value
|
||||
if (!container || scrollTarget === null) return
|
||||
const maxScroll = Math.max(container.scrollWidth - container.clientWidth, 0)
|
||||
scrollTarget = Math.min(Math.max(scrollTarget, 0), maxScroll)
|
||||
const current = container.scrollLeft
|
||||
const diff = scrollTarget - current
|
||||
if (Math.abs(diff) < 0.5) {
|
||||
container.scrollLeft = scrollTarget
|
||||
scrollTarget = null
|
||||
scrollAnimationFrame = null
|
||||
return
|
||||
}
|
||||
container.scrollLeft = current + diff * 0.25
|
||||
scrollAnimationFrame = requestAnimationFrame(animateFilterScroll)
|
||||
}
|
||||
|
||||
function cancelFilterScrollAnimation() {
|
||||
if (scrollAnimationFrame !== null) {
|
||||
cancelAnimationFrame(scrollAnimationFrame)
|
||||
scrollAnimationFrame = null
|
||||
}
|
||||
scrollTarget = null
|
||||
}
|
||||
|
||||
function updateFilterScrollbar() {
|
||||
const container = containerRef.value
|
||||
const thumb = thumbRef.value
|
||||
if (!container || !thumb) return
|
||||
const maxScroll = container.scrollWidth - container.clientWidth
|
||||
if (maxScroll <= 0) {
|
||||
thumb.style.opacity = '0'
|
||||
return
|
||||
}
|
||||
const track = container.clientWidth
|
||||
const thumbWidth = Math.max(24, (track / container.scrollWidth) * track)
|
||||
thumb.style.width = `${thumbWidth}px`
|
||||
thumb.style.transform = `translateX(${(container.scrollLeft / maxScroll) * (track - thumbWidth)}px)`
|
||||
thumb.style.opacity = ''
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
suppressHoverOpen.value = true
|
||||
if (filterHoverSuppressTimer) clearTimeout(filterHoverSuppressTimer)
|
||||
filterHoverSuppressTimer = setTimeout(() => {
|
||||
suppressHoverOpen.value = false
|
||||
filterHoverSuppressTimer = null
|
||||
}, 500)
|
||||
updateFilterScrollbar()
|
||||
}
|
||||
|
||||
watch(
|
||||
containerRef,
|
||||
(container, previous) => {
|
||||
if (previous && filterScrollWheelHandler) {
|
||||
previous.removeEventListener('wheel', filterScrollWheelHandler)
|
||||
filterScrollWheelHandler = null
|
||||
}
|
||||
if (scrollbarObserver) {
|
||||
scrollbarObserver.disconnect()
|
||||
scrollbarObserver = null
|
||||
}
|
||||
if (!container) return
|
||||
|
||||
filterScrollWheelHandler = (event) => {
|
||||
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return
|
||||
if (container.scrollWidth <= container.clientWidth + 1) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
|
||||
const maxScroll = Math.max(container.scrollWidth - container.clientWidth, 0)
|
||||
scrollTarget = Math.min(
|
||||
Math.max((scrollTarget ?? container.scrollLeft) + delta * 0.5, 0),
|
||||
maxScroll,
|
||||
)
|
||||
if (scrollAnimationFrame === null) {
|
||||
scrollAnimationFrame = requestAnimationFrame(animateFilterScroll)
|
||||
}
|
||||
}
|
||||
container.addEventListener('wheel', filterScrollWheelHandler, {
|
||||
passive: false,
|
||||
})
|
||||
|
||||
scrollbarObserver = new ResizeObserver(updateFilterScrollbar)
|
||||
scrollbarObserver.observe(container)
|
||||
updateFilterScrollbar()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (containerRef.value && filterScrollWheelHandler) {
|
||||
containerRef.value.removeEventListener('wheel', filterScrollWheelHandler)
|
||||
filterScrollWheelHandler = null
|
||||
}
|
||||
cancelFilterScrollAnimation()
|
||||
if (scrollbarObserver) {
|
||||
scrollbarObserver.disconnect()
|
||||
scrollbarObserver = null
|
||||
}
|
||||
if (filterHoverSuppressTimer) {
|
||||
clearTimeout(filterHoverSuppressTimer)
|
||||
filterHoverSuppressTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
suppressHoverOpen,
|
||||
handleScroll,
|
||||
}
|
||||
}
|
||||
26
packages/ui/src/layouts/shared/content-tab/index.ts
Normal file
26
packages/ui/src/layouts/shared/content-tab/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export { default as ContentCardItem } from './components/ContentCardItem.vue'
|
||||
export { default as ContentCard } from './components/ContentCardItem.vue'
|
||||
export { default as ContentCardTable } from './components/ContentCardTable.vue'
|
||||
export { default as ConfirmBulkUpdateModal } from './components/modals/ConfirmBulkUpdateModal.vue'
|
||||
export { default as ConfirmDeletionModal } from './components/modals/ConfirmDeletionModal.vue'
|
||||
export { default as ConfirmModpackUpdateModal } from './components/modals/ConfirmModpackUpdateModal.vue'
|
||||
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
|
||||
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
|
||||
export { default as ConfirmUnlinkModal } from './components/modals/ConfirmUnlinkModal.vue'
|
||||
export { default as ContentUpdaterModal } from './components/modals/content-updater-modal/index.vue'
|
||||
export { default as ContentDependencyWarningModal } from './components/modals/ContentDependencyWarningModal.vue'
|
||||
export type {
|
||||
ContentInstallInstance,
|
||||
ContentInstallProjectInfo,
|
||||
ContentInstallProjectOwner,
|
||||
} from './components/modals/ContentInstallModal.vue'
|
||||
export { default as ContentInstallModal } from './components/modals/ContentInstallModal.vue'
|
||||
export type { ModpackContentModalState } from './components/modals/ModpackContentModal.vue'
|
||||
export { default as ModpackContentModal } from './components/modals/ModpackContentModal.vue'
|
||||
export { clearPinnedContentViewPreferences } from './composables/content-view-state'
|
||||
export { default as ContentCardLayout } from './layout.vue'
|
||||
export { default as ContentPageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export * from './types'
|
||||
export * from './utils/update-channels'
|
||||
export { default as ConfirmLeaveModal } from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
1367
packages/ui/src/layouts/shared/content-tab/layout.vue
Normal file
1367
packages/ui/src/layouts/shared/content-tab/layout.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,129 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type {
|
||||
BulkOperationStatus,
|
||||
ContentCardTableItem,
|
||||
ContentItem,
|
||||
ContentModpackCardCategory,
|
||||
ContentModpackCardProject,
|
||||
ContentModpackCardVersion,
|
||||
ContentOwner,
|
||||
} from '../types'
|
||||
|
||||
export interface ContentModpackData {
|
||||
project: ContentModpackCardProject
|
||||
projectLink?: string | RouteLocationRaw
|
||||
version?: ContentModpackCardVersion
|
||||
versionLink?: string | RouteLocationRaw
|
||||
owner?: ContentOwner
|
||||
categories: ContentModpackCardCategory[]
|
||||
hasUpdate: boolean
|
||||
disabled?: boolean
|
||||
disabledText?: string
|
||||
}
|
||||
|
||||
export interface ContentDependencyWarning {
|
||||
items: ContentItem[]
|
||||
dependents: Array<{
|
||||
item: ContentItem
|
||||
dependencies: ContentItem[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ContentManagerContext {
|
||||
// Data
|
||||
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
|
||||
/** Items detected as duplicate content; rendered in a dedicated group when present. */
|
||||
duplicateItems?: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<Error | null>
|
||||
filterOptionsReady?: Ref<boolean> | ComputedRef<boolean>
|
||||
|
||||
// Modpack
|
||||
modpack: Ref<ContentModpackData | null> | ComputedRef<ContentModpackData | null>
|
||||
isPackLocked: Ref<boolean> | ComputedRef<boolean>
|
||||
modpackItems?: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
|
||||
|
||||
// Guards
|
||||
isBusy: Ref<boolean> | ComputedRef<boolean>
|
||||
busyMessage?: Ref<string | null> | ComputedRef<string | null>
|
||||
skipNonEssentialWarnings?: Ref<boolean> | ComputedRef<boolean>
|
||||
disableAddContent?: Ref<boolean> | ComputedRef<boolean>
|
||||
disableAddContentTooltip?: string
|
||||
|
||||
// Labelling
|
||||
contentTypeLabel: Ref<string> | ComputedRef<string>
|
||||
|
||||
// Core actions
|
||||
toggleEnabled: (item: ContentItem) => Promise<void>
|
||||
deleteItem: (item: ContentItem) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
browse: () => void
|
||||
uploadFiles: () => void
|
||||
|
||||
// Bulk actions (optional — when provided, used instead of one-by-one loops)
|
||||
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
|
||||
bulkEnableItems?: (items: ContentItem[]) => Promise<void>
|
||||
bulkDisableItems?: (items: ContentItem[]) => Promise<void>
|
||||
getDeleteDependencyWarning?: (
|
||||
items: ContentItem[],
|
||||
) => ContentDependencyWarning | null | Promise<ContentDependencyWarning | null>
|
||||
|
||||
// Update support (optional per-platform)
|
||||
hasUpdateSupport: boolean
|
||||
updateItem?: (id: string) => void
|
||||
rollbackItem?: (item: ContentItem) => Promise<void>
|
||||
bulkUpdateAll?: (onProgress?: (status: BulkOperationStatus) => void) => Promise<void>
|
||||
bulkUpdateAllLabel?: string
|
||||
bulkUpdateAllDescription?: string
|
||||
bulkUpdateIncludesModpack?: boolean
|
||||
bulkUpdateItem?: (item: ContentItem) => Promise<void>
|
||||
bulkUpdateItems?: (items: ContentItem[]) => Promise<void>
|
||||
|
||||
// Modpack actions (optional)
|
||||
updateModpack?: () => void
|
||||
viewModpackContent?: () => void
|
||||
viewDependencies?: () => void
|
||||
unlinkModpack?: () => void
|
||||
openSettings?: () => void
|
||||
|
||||
// Switch version (optional)
|
||||
switchVersion?: (item: ContentItem) => void
|
||||
|
||||
// Per-item overflow menu (optional)
|
||||
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
|
||||
|
||||
// Share support (optional — when undefined, share button becomes hidden entirely)
|
||||
shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void
|
||||
|
||||
// Stable per-row identity. ContentItem.id can be a content hash, so it is not always unique.
|
||||
getItemId?: (item: ContentItem) => string
|
||||
|
||||
// Bulk operation guard — set by layout, checked by providers to suppress refreshes
|
||||
isBulkOperating?: Ref<boolean>
|
||||
|
||||
// Deletion context (controls modal variant)
|
||||
deletionContext?: 'instance' | 'server'
|
||||
|
||||
// One-time content hint (optional — shows tooltip on modpack content button)
|
||||
showContentHint?: Ref<boolean>
|
||||
dismissContentHint?: () => void
|
||||
|
||||
// Symlink target (optional — when set, modals show symlink warnings)
|
||||
symlinkTarget?: Ref<string | null | undefined> | ComputedRef<string | null | undefined>
|
||||
|
||||
// Table item mapping (link generation differs per platform)
|
||||
mapToTableItem: (item: ContentItem) => ContentCardTableItem
|
||||
|
||||
// 实例/服务器标识,用于内存级 UI 状态隔离(筛选偏好、分组展开等)
|
||||
instanceId?: string
|
||||
}
|
||||
|
||||
export const [injectContentManager, provideContentManager] = createContext<ContentManagerContext>(
|
||||
'ContentPageLayout',
|
||||
'contentManagerContext',
|
||||
)
|
||||
@ -0,0 +1 @@
|
||||
export * from './content-manager'
|
||||
195
packages/ui/src/layouts/shared/content-tab/types.ts
Normal file
195
packages/ui/src/layouts/shared/content-tab/types.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Component } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
|
||||
|
||||
export type ContentCardProject = Pick<
|
||||
Labrinth.Projects.v2.Project,
|
||||
'id' | 'slug' | 'title' | 'icon_url'
|
||||
> & {
|
||||
license?: Labrinth.Projects.v2.Project['license'] | null
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ContentCardVersion = Pick<Labrinth.Versions.v2.Version, 'id' | 'version_number'> & {
|
||||
file_name: string
|
||||
date_published?: string
|
||||
}
|
||||
|
||||
export interface ContentOwner {
|
||||
id: string
|
||||
name: string
|
||||
avatar_url?: string
|
||||
type: 'user' | 'organization'
|
||||
link?: string | RouteLocationRaw | (() => void)
|
||||
}
|
||||
|
||||
export type ClientWarningType = 'retained' | 'depends' | 'environment'
|
||||
|
||||
export interface ContentRowInlineAction {
|
||||
id: string
|
||||
label: string
|
||||
icon: Component
|
||||
action: () => void
|
||||
}
|
||||
|
||||
export interface ContentWorldGroupMeta {
|
||||
icon_url?: string | null
|
||||
title?: string
|
||||
last_played?: string
|
||||
game_mode?: string
|
||||
hardcore?: boolean
|
||||
}
|
||||
|
||||
export interface ContentCardTableItem {
|
||||
id: string
|
||||
project: ContentCardProject
|
||||
projectLink?: string | RouteLocationRaw
|
||||
version?: ContentCardVersion
|
||||
versionLink?: string | RouteLocationRaw
|
||||
owner?: ContentOwner
|
||||
enabled?: boolean
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string | null
|
||||
postUpgradeWarningTooltip?: string | null
|
||||
toggleDisabled?: boolean
|
||||
toggleDisabledTooltip?: string | null
|
||||
installing?: boolean
|
||||
hasUpdate?: boolean
|
||||
/** File name that would be restored by the rollback action, when the item
|
||||
* has an update backup (`{active}_{previous}.old`) available. */
|
||||
rollbackFileName?: string
|
||||
isClientOnly?: boolean
|
||||
clientWarning?: ClientWarningType | null
|
||||
hideSwitchVersion?: boolean
|
||||
pendingManualDownload?: boolean
|
||||
/** Number of installed copies of this online project, when more than one is present. */
|
||||
duplicateCount?: number
|
||||
instanceFileId?: string
|
||||
instanceEntryId?: string
|
||||
instanceMemberId?: string
|
||||
instanceOwnershipKind?: 'pack_managed' | 'user_added' | 'local_discovered'
|
||||
instanceMaterializationState?: 'present' | 'pending_manual' | 'missing' | 'removed'
|
||||
instanceOverrideKind?: 'none' | 'disabled' | 'removed' | 'version'
|
||||
instanceCapabilities?: {
|
||||
canToggle: boolean
|
||||
canDelete: boolean
|
||||
canUpdate: boolean
|
||||
canChangeVersion: boolean
|
||||
canRestorePackDefault: boolean
|
||||
}
|
||||
dependency?: {
|
||||
autoDependency: boolean
|
||||
requiredBy: Array<{
|
||||
provider: 'modrinth' | 'curseforge' | 'local'
|
||||
projectId: string
|
||||
releaseId: string
|
||||
}>
|
||||
requires: Array<{
|
||||
provider: 'modrinth' | 'curseforge' | 'local'
|
||||
projectId: string
|
||||
releaseId: string
|
||||
}>
|
||||
orphaned: boolean
|
||||
} | null
|
||||
dependencyBadge?: {
|
||||
autoDependency: boolean
|
||||
orphaned: boolean
|
||||
} | null
|
||||
overflowOptions?: OverflowMenuOption[]
|
||||
inlineActions?: ContentRowInlineAction[]
|
||||
isGroupHeader?: boolean
|
||||
group?: string
|
||||
groupDepth?: number
|
||||
groupItemCount?: number
|
||||
groupSwitchVersion?: () => void
|
||||
groupChildIds?: string[]
|
||||
isGroupChild?: boolean
|
||||
groupKind?: 'folder' | 'world'
|
||||
groupMeta?: ContentWorldGroupMeta
|
||||
downloads?: number | null
|
||||
followers?: number | null
|
||||
categories?: ContentModpackCardCategory[]
|
||||
}
|
||||
|
||||
export type ContentCardTableSortColumn = 'project' | 'version'
|
||||
export type ContentCardTableSortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface BulkOperationStatus {
|
||||
message?: string
|
||||
progress?: number
|
||||
total?: number
|
||||
waiting?: boolean
|
||||
}
|
||||
|
||||
/** Content item returned from the app backend API - maps to ContentCardTableItem for display */
|
||||
export interface ContentItem extends Omit<
|
||||
ContentCardTableItem,
|
||||
'id' | 'projectLink' | 'disabled' | 'overflowOptions'
|
||||
> {
|
||||
id: string
|
||||
file_name: string
|
||||
file_path?: string
|
||||
size?: number
|
||||
project_type: string
|
||||
/** Provider-qualified update returned by the launcher backend. */
|
||||
update:
|
||||
| {
|
||||
provider: 'modrinth'
|
||||
project_id: string
|
||||
current_version_id: string
|
||||
target_version_id: string
|
||||
}
|
||||
| {
|
||||
provider: 'curseforge'
|
||||
project_id: number
|
||||
current_file_id: number
|
||||
target_file_id: number
|
||||
}
|
||||
| null
|
||||
origin_provider: 'modrinth' | 'curseforge' | null
|
||||
date_added?: string
|
||||
environment?: string
|
||||
/** Local content source kind (local / curseforge / modrinth_modpack / server_project / imported_modpack / shared_instance). */
|
||||
source_kind?: string
|
||||
/** True when the file is not linked to any online project. */
|
||||
external?: boolean
|
||||
/** Loader derived from the installed version or locally parsed mod metadata. */
|
||||
loader?: string
|
||||
pack_client_retained?: boolean
|
||||
pack_client_depends?: boolean
|
||||
installing?: boolean
|
||||
pendingManualDownload?: boolean
|
||||
rollback?: { file_name: string } | null
|
||||
provider_refs: Array<
|
||||
| {
|
||||
provider: 'modrinth'
|
||||
project_id: string
|
||||
version_id: string | null
|
||||
}
|
||||
| {
|
||||
provider: 'curseforge'
|
||||
project_id: number
|
||||
file_id: number | null
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export type ContentModpackCardProject = Pick<
|
||||
Labrinth.Projects.v2.Project,
|
||||
'id' | 'slug' | 'title' | 'icon_url' | 'description'
|
||||
> & {
|
||||
downloads?: number | null
|
||||
followers?: number | null
|
||||
filename?: string | null
|
||||
}
|
||||
|
||||
export type ContentModpackCardVersion = Pick<
|
||||
Labrinth.Versions.v2.Version,
|
||||
'id' | 'version_number' | 'date_published'
|
||||
>
|
||||
|
||||
export type ContentModpackCardCategory = Labrinth.Tags.v2.Category & {
|
||||
action?: (event: MouseEvent) => void
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
export type UpdateChannelPolicy = 'release' | 'beta' | 'alpha'
|
||||
|
||||
const channelRank: Record<UpdateChannelPolicy, number> = {
|
||||
release: 0,
|
||||
beta: 1,
|
||||
alpha: 2,
|
||||
}
|
||||
|
||||
function normalizeChannel(versionType: string): UpdateChannelPolicy {
|
||||
if (versionType === 'alpha' || versionType === 'beta') return versionType
|
||||
return 'release'
|
||||
}
|
||||
|
||||
function effectiveUpdateChannel(
|
||||
policy: UpdateChannelPolicy,
|
||||
currentVersionType?: string | null,
|
||||
): UpdateChannelPolicy {
|
||||
if (!currentVersionType) return policy
|
||||
|
||||
const currentChannel = normalizeChannel(currentVersionType)
|
||||
return channelRank[currentChannel] > channelRank[policy] ? currentChannel : policy
|
||||
}
|
||||
|
||||
function channelFallbacks(policy: UpdateChannelPolicy): UpdateChannelPolicy[][] {
|
||||
switch (policy) {
|
||||
case 'release':
|
||||
return [['release'], ['beta'], ['alpha']]
|
||||
case 'beta':
|
||||
return [['release', 'beta'], ['alpha']]
|
||||
case 'alpha':
|
||||
return [['release', 'beta', 'alpha']]
|
||||
}
|
||||
}
|
||||
|
||||
export function allowsUpdateChannel(
|
||||
version: Pick<Labrinth.Versions.v2.Version, 'version_type'>,
|
||||
policy: UpdateChannelPolicy,
|
||||
currentVersionType?: string | null,
|
||||
) {
|
||||
const effectivePolicy = effectiveUpdateChannel(policy, currentVersionType)
|
||||
return channelFallbacks(effectivePolicy)[0].includes(normalizeChannel(version.version_type))
|
||||
}
|
||||
|
||||
export function newestEligibleUpdate(
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
currentVersionId: string,
|
||||
currentPublishedAt: string | null | undefined,
|
||||
policy: UpdateChannelPolicy,
|
||||
currentVersionType?: string | null,
|
||||
) {
|
||||
const currentTime = currentPublishedAt ? new Date(currentPublishedAt).getTime() : Number.NaN
|
||||
const sortedVersions = [...versions].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
const effectivePolicy = effectiveUpdateChannel(policy, currentVersionType)
|
||||
|
||||
for (const versionTypes of channelFallbacks(effectivePolicy)) {
|
||||
if (
|
||||
!versions.some((version) => versionTypes.includes(normalizeChannel(version.version_type)))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
return (
|
||||
sortedVersions.find((version) => {
|
||||
if (version.id === currentVersionId) return false
|
||||
if (!versionTypes.includes(normalizeChannel(version.version_type))) return false
|
||||
if (Number.isNaN(currentTime)) return true
|
||||
return new Date(version.date_published).getTime() > currentTime
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="transform scale-75 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-125 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-75 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="fixed isolate z-[9999] flex w-fit min-w-[180px] flex-col gap-2 overflow-hidden rounded-2xl border border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleCopyFilename"
|
||||
>
|
||||
<ClipboardCopyIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<ClipboardCopyIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="ctx.openInFolder" type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleOpenInFolder"
|
||||
>
|
||||
<FolderOpenIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.openInFolderButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="h-px w-full bg-surface-5" />
|
||||
<template v-for="(option, index) in menuOptions" :key="index">
|
||||
<div
|
||||
v-if="'divider' in option && option.divider && option.shown !== false"
|
||||
class="h-px w-full bg-surface-5"
|
||||
/>
|
||||
<ButtonStyled
|
||||
v-else-if="'id' in option && option.shown !== false"
|
||||
type="transparent"
|
||||
:color="option.color"
|
||||
>
|
||||
<button
|
||||
v-tooltip="option.tooltip"
|
||||
:disabled="option.disabled"
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleOptionClick(option)"
|
||||
>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.label ?? option.id }}
|
||||
</slot>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
import type { FileContextMenuOption, FileItem } from '../types'
|
||||
import { joinDisplayPath } from '../utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const visible = ref(false)
|
||||
const menuRef = ref<HTMLElement>()
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const currentItem = ref<FileItem | null>(null)
|
||||
const menuOptions = ref<FileContextMenuOption[]>([])
|
||||
|
||||
function show(item: FileItem, x: number, y: number, options: typeof menuOptions.value) {
|
||||
currentItem.value = item
|
||||
menuOptions.value = options
|
||||
position.value = { x, y }
|
||||
visible.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (!menuRef.value) return
|
||||
const rect = menuRef.value.getBoundingClientRect()
|
||||
const padding = 10
|
||||
if (rect.right > window.innerWidth - padding) {
|
||||
position.value.x = Math.max(padding, x - rect.width)
|
||||
}
|
||||
if (rect.bottom > window.innerHeight - padding) {
|
||||
position.value.y = Math.max(padding, y - rect.height)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
currentItem.value = null
|
||||
}
|
||||
|
||||
function handleCopyFilename() {
|
||||
if (!currentItem.value) return
|
||||
navigator.clipboard.writeText(currentItem.value.name)
|
||||
addNotification({ title: formatMessage(commonMessages.copiedFilenameLabel), type: 'success' })
|
||||
hide()
|
||||
}
|
||||
|
||||
function getFullPath() {
|
||||
if (!currentItem.value) return ''
|
||||
return joinDisplayPath(ctx.basePath?.value, currentItem.value.path)
|
||||
}
|
||||
|
||||
function handleCopyPath() {
|
||||
if (!currentItem.value) return
|
||||
navigator.clipboard.writeText(getFullPath())
|
||||
addNotification({ title: formatMessage(commonMessages.copiedPathLabel), type: 'success' })
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleOpenInFolder() {
|
||||
if (!currentItem.value) return
|
||||
ctx.openInFolder?.(getFullPath())
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleOptionClick(option: { action?: () => void }) {
|
||||
option.action?.()
|
||||
hide()
|
||||
}
|
||||
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function onEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
document.addEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onClickOutside)
|
||||
document.removeEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (!v) currentItem.value = null
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
|
||||
<FileIcon class="size-28" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('refetch')">
|
||||
<RefreshCwIcon class="h-5 w-5" />
|
||||
{{ formatMessage(messages.tryAgain) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('home')">
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
{{ formatMessage(messages.goToHome) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, HomeIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
tryAgain: {
|
||||
id: 'files.error.try-again',
|
||||
defaultMessage: 'Try again',
|
||||
},
|
||||
goToHome: {
|
||||
id: 'files.error.go-to-home',
|
||||
defaultMessage: 'Go to home folder',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
message: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refetch: []
|
||||
home: []
|
||||
}>()
|
||||
</script>
|
||||
@ -0,0 +1,454 @@
|
||||
<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="onSearchInput"
|
||||
/>
|
||||
</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="onSearchInput"
|
||||
/>
|
||||
|
||||
<slot name="before-refresh" />
|
||||
|
||||
<ButtonStyled v-if="showRefreshButton" type="outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="flex !h-10 items-center gap-2"
|
||||
: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"
|
||||
:options="[
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||
{ 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-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" class="flex gap-2">
|
||||
<ButtonStyled v-if="isLogFile" type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.shareLog)"
|
||||
:aria-label="formatMessage(messages.shareLog)"
|
||||
@click="$emit('share')"
|
||||
>
|
||||
<ShareIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
circular
|
||||
:type="isEditorFindOpen ? 'standard' : 'transparent'"
|
||||
:color="isEditorFindOpen ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.findInFile)"
|
||||
:aria-label="formatMessage(messages.findInFile)"
|
||||
:aria-pressed="isEditorFindOpen"
|
||||
@click="$emit('find')"
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
ChevronRightIcon,
|
||||
CurseForgeIcon,
|
||||
DropdownIcon,
|
||||
FileArchiveIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
ShareIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from '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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
shareLog: {
|
||||
id: 'files.navbar.share-log',
|
||||
defaultMessage: 'Share log',
|
||||
},
|
||||
findInFile: {
|
||||
id: 'files.navbar.find-in-file',
|
||||
defaultMessage: 'Find in file',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbs: string[]
|
||||
isEditing: boolean
|
||||
editingFileName?: string
|
||||
editingFilePath?: string
|
||||
isEditingImage?: boolean
|
||||
isEditorFindOpen?: 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: []
|
||||
find: []
|
||||
}>()
|
||||
|
||||
const refreshing = ref(false)
|
||||
|
||||
function onSearchInput(value: string | number | undefined) {
|
||||
emit('update:searchQuery', String(value ?? ''))
|
||||
}
|
||||
|
||||
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>
|
||||
@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="sticky top-0 z-10 flex h-12 w-full select-none flex-row items-center justify-between bg-surface-3 pl-3 pr-4 font-medium transition-[border-radius] duration-100"
|
||||
:class="
|
||||
isStuck
|
||||
? 'rounded-none border-0 border-y border-solid border-surface-4 shadow-md before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-5 before:bg-surface-3'
|
||||
: 'rounded-t-[20px]'
|
||||
"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
@update:model-value="$emit('toggle-all')"
|
||||
/>
|
||||
<button
|
||||
class="flex appearance-none items-center gap-1.5 border-0 bg-transparent p-0 font-semibold hover:text-primary"
|
||||
:class="sortField === 'name' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'name')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.name) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'name' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'name' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-4 @[800px]:gap-12">
|
||||
<button
|
||||
class="hidden w-[100px] appearance-none items-center justify-start gap-1 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'size')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.size) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'size' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'size' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="hidden w-[160px] appearance-none items-center justify-start gap-1 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'created' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'created')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.created) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'created' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'created' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="hidden w-[160px] appearance-none items-center justify-start gap-1 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'modified')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.modified) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'modified' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'modified' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<span class="min-w-[51px] shrink-0 text-nowrap text-right font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.actionsLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileSortField } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
name: {
|
||||
id: 'files.table-header.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
size: {
|
||||
id: 'files.table-header.size',
|
||||
defaultMessage: 'Size',
|
||||
},
|
||||
created: {
|
||||
id: 'files.table-header.created',
|
||||
defaultMessage: 'Created',
|
||||
},
|
||||
modified: {
|
||||
id: 'files.table-header.modified',
|
||||
defaultMessage: 'Modified',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
sortField: FileSortField
|
||||
sortDesc: boolean
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
isStuck: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
sort: [field: FileSortField]
|
||||
'toggle-all': []
|
||||
}>()
|
||||
</script>
|
||||
@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<li
|
||||
role="button"
|
||||
:class="[containerClasses, isDragSource ? 'opacity-50' : '']"
|
||||
tabindex="0"
|
||||
:data-file-path="path"
|
||||
:data-file-type="type"
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<div class="pointer-events-none flex flex-1 items-center gap-3 truncate">
|
||||
<Checkbox
|
||||
class="pointer-events-auto"
|
||||
:model-value="selected"
|
||||
@click.stop
|
||||
@update:model-value="emit('toggle-select')"
|
||||
/>
|
||||
<div class="pointer-events-none flex size-5 items-center justify-center">
|
||||
<component
|
||||
:is="iconComponent"
|
||||
class="size-5 group-hover:text-contrast group-focus:text-contrast"
|
||||
/>
|
||||
</div>
|
||||
<div class="pointer-events-none flex flex-col truncate">
|
||||
<span
|
||||
class="pointer-events-none truncate group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 @[800px]:gap-12">
|
||||
<span class="hidden w-[100px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedSize }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedCreationDate }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedModifiedDate }}
|
||||
</span>
|
||||
<div class="grid min-w-[51px] shrink-0 items-center justify-items-end">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="invisible col-start-1 row-start-1 text-nowrap font-semibold"
|
||||
>
|
||||
{{ formatMessage(commonMessages.actionsLabel) }}
|
||||
</span>
|
||||
<div class="col-start-1 row-start-1 flex justify-end">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<TeleportOverflowMenu :options="menuOptions">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #copy-filename
|
||||
><ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}</template
|
||||
>
|
||||
<template #copy-full-path
|
||||
><ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}</template
|
||||
>
|
||||
<template #open-in-folder
|
||||
><FolderOpenIcon /> {{ formatMessage(commonMessages.openInFolderButton) }}</template
|
||||
>
|
||||
<template #extract
|
||||
><PackageOpenIcon /> {{ formatMessage(commonMessages.extractButton) }}</template
|
||||
>
|
||||
<template #rename
|
||||
><EditIcon /> {{ formatMessage(commonMessages.renameButton) }}</template
|
||||
>
|
||||
<template #move
|
||||
><RightArrowIcon /> {{ formatMessage(commonMessages.moveButton) }}</template
|
||||
>
|
||||
<template #download
|
||||
><DownloadIcon />
|
||||
{{
|
||||
ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton)
|
||||
}}</template
|
||||
>
|
||||
<template #delete
|
||||
><TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||
>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
BracesIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderCogIcon,
|
||||
FolderOpenIcon,
|
||||
GlassesIcon,
|
||||
GlobeIcon,
|
||||
MoreHorizontalIcon,
|
||||
PackageOpenIcon,
|
||||
PaintbrushIcon,
|
||||
RightArrowIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
import { useFormatDateTime } from '#ui/composables/format-date-time'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { getFileExtensionIcon } from '#ui/utils/auto-icons'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { canOpenInFileEditor, getFileExtension } from '#ui/utils/file-extensions'
|
||||
|
||||
import {
|
||||
fileDragActive,
|
||||
fileDragData,
|
||||
fileDragTarget,
|
||||
startFileDrag,
|
||||
wasRecentDrag,
|
||||
} from '../composables/file-drag-state'
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
import type { FileItem } from '../types'
|
||||
import { joinDisplayPath } from '../utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
itemCount: {
|
||||
id: 'files.row.item-count',
|
||||
defaultMessage: '{count, plural, one {# item} other {# items}}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<
|
||||
FileItem & {
|
||||
index: number
|
||||
isLast: boolean
|
||||
selected: boolean
|
||||
writeDisabled?: boolean
|
||||
writeDisabledTooltip?: string
|
||||
}
|
||||
>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover' | 'navigate',
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'>,
|
||||
): void
|
||||
(
|
||||
e: 'moveDirectTo',
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'> & { destination: string },
|
||||
): void
|
||||
(e: 'contextmenu', x: number, y: number): void
|
||||
(e: 'toggle-select'): void
|
||||
}>()
|
||||
|
||||
const isDropTarget = computed(
|
||||
() => fileDragActive.value && fileDragTarget.value === props.path && props.type === 'directory',
|
||||
)
|
||||
const isDragSource = computed(() => fileDragActive.value && fileDragData.value?.path === props.path)
|
||||
|
||||
const formatDateTime = useFormatDateTime({
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const dropTarget = isDropTarget.value
|
||||
return [
|
||||
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-4 pl-3 pr-4 py-3 focus:!outline-none',
|
||||
dropTarget
|
||||
? '!bg-brand-highlight'
|
||||
: props.selected
|
||||
? 'bg-surface-2.5'
|
||||
: props.index % 2 === 0
|
||||
? 'bg-surface-2'
|
||||
: 'bg-surface-1.5',
|
||||
props.isLast ? 'rounded-b-[20px]' : '',
|
||||
isEditableFile.value || props.type === 'directory' ? 'cursor-pointer hover:bg-surface-2.5' : '',
|
||||
'transition-colors duration-100 focus:!outline-none',
|
||||
]
|
||||
})
|
||||
|
||||
const fileExtension = computed(() => getFileExtension(props.name))
|
||||
|
||||
const isZip = computed(() => fileExtension.value === 'zip')
|
||||
|
||||
function getFullPath() {
|
||||
return joinDisplayPath(ctx.basePath?.value, props.path)
|
||||
}
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
const item = { name: props.name, type: props.type, path: props.path }
|
||||
const wd = props.writeDisabled
|
||||
const wdTooltip = props.writeDisabledTooltip
|
||||
const additionalOptions = ctx.getAdditionalMenuOptions?.(item) ?? []
|
||||
const hasAdditionalOptions = additionalOptions.some((option) => option.shown !== false)
|
||||
return [
|
||||
{
|
||||
id: 'copy-filename',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.name)
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.copiedFilenameLabel),
|
||||
type: 'success',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'copy-full-path',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(getFullPath())
|
||||
addNotification({ title: formatMessage(commonMessages.copiedPathLabel), type: 'success' })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'open-in-folder',
|
||||
icon: FolderOpenIcon,
|
||||
shown: !!ctx.openInFolder,
|
||||
action: () => ctx.openInFolder?.(getFullPath()),
|
||||
},
|
||||
{ divider: true },
|
||||
...additionalOptions,
|
||||
{ divider: true, shown: hasAdditionalOptions },
|
||||
{
|
||||
id: 'extract',
|
||||
shown: isZip.value,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('extract', item),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: isZip.value,
|
||||
},
|
||||
{
|
||||
id: 'rename',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('rename', item),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('move', item),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
action: () => emit('download', item),
|
||||
shown: props.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('delete', item),
|
||||
color: 'red' as const,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
if (props.name === 'config') return FolderCogIcon
|
||||
if (props.name === 'world' || props.name === 'saves') return GlobeIcon
|
||||
if (props.name === 'mods') return BoxIcon
|
||||
if (props.name === 'resourcepacks') return PaintbrushIcon
|
||||
if (props.name === 'shaderpacks') return GlassesIcon
|
||||
if (props.name === 'datapacks') return BracesIcon
|
||||
return FolderOpenIcon
|
||||
}
|
||||
|
||||
return getFileExtensionIcon(fileExtension.value)
|
||||
})
|
||||
|
||||
const formattedModifiedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000)
|
||||
return formatDateTime(date)
|
||||
})
|
||||
|
||||
const formattedCreationDate = computed(() => {
|
||||
const date = new Date(props.created * 1000)
|
||||
return formatDateTime(date)
|
||||
})
|
||||
|
||||
const isEditableFile = computed(() => {
|
||||
if (props.type === 'file') {
|
||||
return canOpenInFileEditor(props.name)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
return formatMessage(messages.itemCount, { count: props.count ?? 0 })
|
||||
}
|
||||
|
||||
if (props.size === undefined) return ''
|
||||
return formatBytes(props.size)
|
||||
})
|
||||
|
||||
function openContextMenu(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
emit('contextmenu', event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
emit('hover', { name: props.name, type: props.type, path: props.path })
|
||||
}
|
||||
|
||||
const isNavigating = ref(false)
|
||||
|
||||
function selectItem() {
|
||||
if (isNavigating.value || wasRecentDrag()) return
|
||||
isNavigating.value = true
|
||||
|
||||
const item = { name: props.name, type: props.type, path: props.path }
|
||||
if (props.type === 'directory') {
|
||||
emit('navigate', item)
|
||||
} else if (props.type === 'file' && isEditableFile.value) {
|
||||
emit('edit', item)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigating.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
startFileDrag(
|
||||
{ name: props.name, type: props.type, path: props.path },
|
||||
e,
|
||||
(source, destination) => {
|
||||
emit('moveDirectTo', {
|
||||
name: source.name,
|
||||
type: source.type as FileItem['type'],
|
||||
path: source.path,
|
||||
destination,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<Transition name="find">
|
||||
<div
|
||||
v-if="isFindOpen && !isEditingImage"
|
||||
class="absolute right-3 top-3 z-10 flex flex-col gap-1 rounded-2xl border border-solid border-surface-5 bg-surface-3 p-1.5 shadow-lg"
|
||||
@keydown.escape.stop="close"
|
||||
>
|
||||
<!-- Find row -->
|
||||
<div class="flex items-center gap-1">
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.toggleReplace)"
|
||||
:disabled="props.readonly"
|
||||
:aria-label="formatMessage(messages.toggleReplace)"
|
||||
@click="toggleReplace"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
class="transition-transform duration-150"
|
||||
:class="{ 'rotate-90': isReplaceOpen }"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
@keydown.enter.exact.prevent.stop="emit('findNext')"
|
||||
@keydown.shift.enter.prevent.stop="emit('findPrevious')"
|
||||
>
|
||||
<StyledInput
|
||||
ref="findInputRef"
|
||||
:model-value="findQuery"
|
||||
type="search"
|
||||
size="small"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.findInFile)"
|
||||
wrapper-class="w-44"
|
||||
@update:model-value="emit('update:findQuery', $event as string)"
|
||||
/>
|
||||
</div>
|
||||
<span class="min-w-[6rem] px-1 text-sm text-secondary tabular-nums">
|
||||
{{
|
||||
findMatchCount > 0
|
||||
? formatMessage(messages.matchCount, {
|
||||
current: currentFindMatch,
|
||||
total: findMatchCount,
|
||||
})
|
||||
: findQuery
|
||||
? formatMessage(messages.noResults)
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.previousMatch)"
|
||||
:disabled="findMatchCount === 0"
|
||||
:aria-label="formatMessage(messages.previousMatch)"
|
||||
@click="emit('findPrevious')"
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.nextMatch)"
|
||||
:disabled="findMatchCount === 0"
|
||||
:aria-label="formatMessage(messages.nextMatch)"
|
||||
@click="emit('findNext')"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="mx-0.5 h-4 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.closeFind)"
|
||||
:aria-label="formatMessage(messages.closeFind)"
|
||||
@click="close"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<!-- Replace row -->
|
||||
<div v-if="isReplaceOpen" class="flex items-center gap-1">
|
||||
<div class="w-9 flex-shrink-0" />
|
||||
<div @keydown.enter.prevent.stop="emit('replace', replaceQuery)">
|
||||
<StyledInput
|
||||
ref="replaceInputRef"
|
||||
v-model="replaceQuery"
|
||||
type="search"
|
||||
size="small"
|
||||
autocomplete="off"
|
||||
:disabled="props.readonly"
|
||||
:placeholder="formatMessage(messages.replaceInFile)"
|
||||
wrapper-class="w-44"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-8 whitespace-nowrap px-2 text-sm disabled:opacity-50"
|
||||
:disabled="props.readonly || findMatchCount === 0"
|
||||
@click="emit('replace', replaceQuery)"
|
||||
>
|
||||
{{ formatMessage(messages.replace) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-8 whitespace-nowrap px-2 text-sm disabled:opacity-50"
|
||||
:disabled="props.readonly || findMatchCount === 0"
|
||||
@click="emit('replaceAll', replaceQuery)"
|
||||
>
|
||||
{{ formatMessage(messages.replaceAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
isFindOpen: boolean
|
||||
findQuery: string
|
||||
findMatchCount: number
|
||||
currentFindMatch: number
|
||||
isEditingImage: boolean
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:isFindOpen': [value: boolean]
|
||||
'update:findQuery': [value: string]
|
||||
close: []
|
||||
findNext: []
|
||||
findPrevious: []
|
||||
replace: [query: string]
|
||||
replaceAll: [query: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
findInFile: {
|
||||
id: 'files.editor.find-in-file',
|
||||
defaultMessage: 'Find',
|
||||
},
|
||||
matchCount: {
|
||||
id: 'files.editor.find-match-count',
|
||||
defaultMessage: '{current} of {total}',
|
||||
},
|
||||
noResults: {
|
||||
id: 'files.editor.find-no-results',
|
||||
defaultMessage: 'No results',
|
||||
},
|
||||
previousMatch: {
|
||||
id: 'files.editor.find-previous-match',
|
||||
defaultMessage: 'Previous match',
|
||||
},
|
||||
nextMatch: {
|
||||
id: 'files.editor.find-next-match',
|
||||
defaultMessage: 'Next match',
|
||||
},
|
||||
closeFind: {
|
||||
id: 'files.editor.find-close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
toggleReplace: {
|
||||
id: 'files.editor.find-toggle-replace',
|
||||
defaultMessage: 'Toggle replace',
|
||||
},
|
||||
replaceInFile: {
|
||||
id: 'files.editor.replace-in-file',
|
||||
defaultMessage: 'Replace',
|
||||
},
|
||||
replace: {
|
||||
id: 'files.editor.replace',
|
||||
defaultMessage: 'Replace',
|
||||
},
|
||||
replaceAll: {
|
||||
id: 'files.editor.replace-all',
|
||||
defaultMessage: 'Replace All',
|
||||
},
|
||||
})
|
||||
|
||||
const isReplaceOpen = ref(false)
|
||||
const replaceQuery = ref('')
|
||||
|
||||
const findInputRef = ref<{ focus: () => void } | null>(null)
|
||||
const replaceInputRef = ref<{ focus: () => void } | null>(null)
|
||||
|
||||
function toggleReplace() {
|
||||
if (props.readonly) return
|
||||
isReplaceOpen.value = !isReplaceOpen.value
|
||||
if (isReplaceOpen.value) {
|
||||
nextTick(() => replaceInputRef.value?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
function focusFindInput() {
|
||||
nextTick(() => findInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function openReplace() {
|
||||
if (props.readonly) return
|
||||
isReplaceOpen.value = true
|
||||
nextTick(() => replaceInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function close() {
|
||||
isReplaceOpen.value = false
|
||||
replaceQuery.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isFindOpen,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
isReplaceOpen.value = false
|
||||
replaceQuery.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
focusFindInput,
|
||||
openReplace,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.find-enter-active,
|
||||
.find-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.find-enter-from,
|
||||
.find-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.97);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div
|
||||
ref="editorContainer"
|
||||
class="relative flex flex-col overflow-hidden rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
>
|
||||
<EditorFindReplace
|
||||
ref="findReplaceRef"
|
||||
v-model:is-find-open="isFindOpen"
|
||||
v-model:find-query="inFileFindQuery"
|
||||
:is-editing-image="isEditingImage"
|
||||
:readonly="isEditorReadOnly"
|
||||
:find-match-count="findMatchCount"
|
||||
:current-find-match="currentFindMatch"
|
||||
@find-next="findNext"
|
||||
@find-previous="findPrevious"
|
||||
@close="closeFind"
|
||||
@replace="replaceOne"
|
||||
@replace-all="replaceAllOccurrences"
|
||||
/>
|
||||
<component
|
||||
:is="props.editorComponent"
|
||||
v-if="!isEditingImage && !isLoading && props.editorComponent"
|
||||
v-model:value="fileContent"
|
||||
:lang="editorLanguage"
|
||||
theme="modrinth"
|
||||
:readonly="isEditorReadOnly"
|
||||
:print-margin="false"
|
||||
:style="{ height: editorHeight, fontSize: '0.875rem' }"
|
||||
class="ace-modrinth rounded-[20px]"
|
||||
@init="onEditorInit"
|
||||
/>
|
||||
<FileImageViewer v-else-if="isEditingImage && imagePreview" :image-blob="imagePreview" />
|
||||
<textarea
|
||||
v-else-if="!isEditingImage && !isLoading"
|
||||
:value="fileContent"
|
||||
:readonly="isEditorReadOnly"
|
||||
:placeholder="formatMessage(messages.editorUnavailablePlaceholder)"
|
||||
class="w-full resize-none rounded-[20px] bg-bg-raised p-3 font-mono text-sm text-primary outline-none"
|
||||
:style="{ height: editorHeight }"
|
||||
@input="onFallbackInput"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isLoading"
|
||||
class="flex items-center justify-center rounded-[20px] bg-bg-raised"
|
||||
:style="{ height: editorHeight }"
|
||||
>
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import type { Ace } from 'ace-builds'
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { getEditorLanguage, getFileExtension, isImageFile } from '#ui/utils/file-extensions'
|
||||
import { shareLogs } from '#ui/utils/log-share'
|
||||
|
||||
import { injectFileManager } from '../../providers/file-manager'
|
||||
import type { EditingFile } from '../../types'
|
||||
import EditorFindReplace from './EditorFindReplace.vue'
|
||||
import FileImageViewer from './FileImageViewer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
file: EditingFile | null
|
||||
editorComponent: Component | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const messages = defineMessages({
|
||||
failedToOpenTitle: {
|
||||
id: 'files.editor.failed-to-open-title',
|
||||
defaultMessage: 'Failed to open file',
|
||||
},
|
||||
failedToOpenText: {
|
||||
id: 'files.editor.failed-to-open-text',
|
||||
defaultMessage: 'Could not load file contents.',
|
||||
},
|
||||
fileSavedTitle: {
|
||||
id: 'files.editor.file-saved-title',
|
||||
defaultMessage: 'File saved',
|
||||
},
|
||||
fileSavedText: {
|
||||
id: 'files.editor.file-saved-text',
|
||||
defaultMessage: 'Your file has been saved.',
|
||||
},
|
||||
saveFailedTitle: {
|
||||
id: 'files.editor.save-failed-title',
|
||||
defaultMessage: 'Save failed',
|
||||
},
|
||||
saveFailedText: {
|
||||
id: 'files.editor.save-failed-text',
|
||||
defaultMessage: 'Could not save the file.',
|
||||
},
|
||||
logUrlCopiedTitle: {
|
||||
id: 'files.editor.log-url-copied-title',
|
||||
defaultMessage: 'Log URL copied',
|
||||
},
|
||||
logUrlCopiedText: {
|
||||
id: 'files.editor.log-url-copied-text',
|
||||
defaultMessage: 'Your log file URL has been copied to your clipboard.',
|
||||
},
|
||||
failedToShareTitle: {
|
||||
id: 'files.editor.failed-to-share-title',
|
||||
defaultMessage: 'Failed to share file',
|
||||
},
|
||||
failedToShareText: {
|
||||
id: 'files.editor.failed-to-share-text',
|
||||
defaultMessage: 'Could not share the log file.',
|
||||
},
|
||||
logTruncatedWarning: {
|
||||
id: 'files.editor.share-truncated-warning',
|
||||
defaultMessage: 'The log file is too large, so only the last 9 MB was uploaded.',
|
||||
},
|
||||
editorUnavailablePlaceholder: {
|
||||
id: 'files.editor.editor-unavailable-placeholder',
|
||||
defaultMessage: 'Code editor unavailable',
|
||||
},
|
||||
})
|
||||
|
||||
const fileContent = ref('')
|
||||
const originalContent = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const imagePreview = ref<Blob | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const editorInstance = ref<Ace.Editor | null>(null)
|
||||
const editorContainer = ref<HTMLElement | null>(null)
|
||||
const editorHeight = ref('300px')
|
||||
|
||||
const isFindOpen = ref(false)
|
||||
const inFileFindQuery = ref('')
|
||||
const findMatchCount = ref(0)
|
||||
const currentFindMatch = ref(0)
|
||||
const findReplaceRef = ref<{ focusFindInput: () => void; openReplace: () => void } | null>(null)
|
||||
|
||||
watch(inFileFindQuery, handleFindInput)
|
||||
|
||||
function updateEditorHeight() {
|
||||
if (editorContainer.value) {
|
||||
const top = editorContainer.value.getBoundingClientRect().top
|
||||
const padding = 24
|
||||
editorHeight.value = `${Math.max(300, window.innerHeight - top - padding)}px`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(updateEditorHeight)
|
||||
window.addEventListener('resize', updateEditorHeight)
|
||||
})
|
||||
|
||||
const editorLanguage = computed(() => {
|
||||
const ext = getFileExtension(props.file?.name ?? '')
|
||||
return getEditorLanguage(ext)
|
||||
})
|
||||
const isEditorReadOnly = computed(() => ctx.isBusy?.value ?? false)
|
||||
|
||||
watch(isEditorReadOnly, (readOnly) => {
|
||||
editorInstance.value?.setReadOnly(readOnly)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
async (newFile) => {
|
||||
if (newFile) {
|
||||
closeFind()
|
||||
await loadFileContent(newFile)
|
||||
nextTick(updateEditorHeight)
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadFileContent(file: { name: string; path: string }) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
window.scrollTo(0, 0)
|
||||
const extension = getFileExtension(file.name)
|
||||
const normalizedPath = file.path.startsWith('/') ? file.path : `/${file.path}`
|
||||
|
||||
if (isImageFile(extension)) {
|
||||
const content = await ctx.readFileAsBlob(normalizedPath)
|
||||
isEditingImage.value = true
|
||||
imagePreview.value = content
|
||||
} else {
|
||||
isEditingImage.value = false
|
||||
const content = await ctx.readFile(normalizedPath)
|
||||
fileContent.value = content
|
||||
originalContent.value = content
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file content:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.failedToOpenTitle),
|
||||
text: formatMessage(messages.failedToOpenText),
|
||||
type: 'error',
|
||||
})
|
||||
emit('close')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() => !isEditingImage.value && !isLoading.value && fileContent.value !== originalContent.value,
|
||||
)
|
||||
|
||||
function revertChanges() {
|
||||
fileContent.value = originalContent.value
|
||||
}
|
||||
|
||||
function onFallbackInput(event: Event) {
|
||||
fileContent.value = (event.target as HTMLTextAreaElement).value
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
fileContent.value = ''
|
||||
originalContent.value = ''
|
||||
isEditingImage.value = false
|
||||
imagePreview.value = null
|
||||
}
|
||||
|
||||
function onEditorInit(editor: Ace.Editor) {
|
||||
editorInstance.value = editor
|
||||
editor.setReadOnly(isEditorReadOnly.value)
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'save',
|
||||
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
|
||||
exec: () => saveFileContent(false),
|
||||
})
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'find',
|
||||
bindKey: { win: 'Ctrl-F', mac: 'Command-F' },
|
||||
exec: () => toggleFind(),
|
||||
})
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'replace',
|
||||
bindKey: { win: 'Ctrl-H', mac: 'Command-Option-F' },
|
||||
exec: () => {
|
||||
if (isEditorReadOnly.value) return
|
||||
isFindOpen.value = true
|
||||
nextTick(() => findReplaceRef.value?.openReplace())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function saveFileContent(exit: boolean = false) {
|
||||
if (!props.file) return
|
||||
if (ctx.isBusy?.value) return
|
||||
|
||||
try {
|
||||
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
|
||||
await ctx.writeFile(normalizedPath, fileContent.value)
|
||||
|
||||
originalContent.value = fileContent.value
|
||||
|
||||
if (exit) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: formatMessage(messages.fileSavedTitle),
|
||||
text: formatMessage(messages.fileSavedText),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving file content:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.saveFailedTitle),
|
||||
text: formatMessage(messages.saveFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function shareLog() {
|
||||
if (ctx.shareLogs) {
|
||||
await ctx.shareLogs(fileContent.value)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await shareLogs(client, fileContent.value)
|
||||
|
||||
if (result.url) {
|
||||
if (result.truncated) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.logTruncatedWarning),
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
await navigator.clipboard.writeText(result.url)
|
||||
addNotification({
|
||||
title: formatMessage(messages.logUrlCopiedTitle),
|
||||
text: formatMessage(messages.logUrlCopiedText),
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
throw new Error('log share failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing file:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.failedToShareTitle),
|
||||
text: formatMessage(messages.failedToShareText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, query: string): number {
|
||||
if (!query) return 0
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return (content.match(new RegExp(escaped, 'gi')) ?? []).length
|
||||
}
|
||||
|
||||
function toggleFind() {
|
||||
if (isFindOpen.value) {
|
||||
closeFind()
|
||||
} else {
|
||||
isFindOpen.value = true
|
||||
nextTick(() => findReplaceRef.value?.focusFindInput())
|
||||
}
|
||||
}
|
||||
|
||||
function closeFind() {
|
||||
isFindOpen.value = false
|
||||
inFileFindQuery.value = ''
|
||||
findMatchCount.value = 0
|
||||
currentFindMatch.value = 0
|
||||
editorInstance.value?.find('', { wrap: true })
|
||||
editorInstance.value?.focus()
|
||||
}
|
||||
|
||||
function replaceOne(query: string) {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
|
||||
editor.replace(query)
|
||||
nextTick(() => {
|
||||
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
|
||||
findMatchCount.value = count
|
||||
currentFindMatch.value = count > 0 ? Math.min(currentFindMatch.value, count) : 0
|
||||
})
|
||||
}
|
||||
|
||||
function replaceAllOccurrences(query: string) {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
|
||||
editor.replaceAll(query)
|
||||
nextTick(() => {
|
||||
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
|
||||
findMatchCount.value = count
|
||||
currentFindMatch.value = count > 0 ? 1 : 0
|
||||
if (count > 0) {
|
||||
editor.find(inFileFindQuery.value, { wrap: true, caseSensitive: false })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleFindInput() {
|
||||
const editor = editorInstance.value
|
||||
if (!editor) return
|
||||
|
||||
const query = inFileFindQuery.value
|
||||
if (!query) {
|
||||
findMatchCount.value = 0
|
||||
currentFindMatch.value = 0
|
||||
editor.find('', { wrap: true })
|
||||
return
|
||||
}
|
||||
|
||||
const count = countOccurrences(fileContent.value, query)
|
||||
findMatchCount.value = count
|
||||
|
||||
if (count > 0) {
|
||||
editor.find(query, { wrap: true, caseSensitive: false })
|
||||
currentFindMatch.value = 1
|
||||
} else {
|
||||
currentFindMatch.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function findNext() {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || findMatchCount.value === 0) return
|
||||
editor.findNext()
|
||||
currentFindMatch.value = (currentFindMatch.value % findMatchCount.value) + 1
|
||||
}
|
||||
|
||||
function findPrevious() {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || findMatchCount.value === 0) return
|
||||
editor.findPrevious()
|
||||
currentFindMatch.value =
|
||||
((currentFindMatch.value - 2 + findMatchCount.value) % findMatchCount.value) + 1
|
||||
}
|
||||
|
||||
function close() {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateEditorHeight)
|
||||
editorInstance.value = null
|
||||
resetState()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
saveFileContent,
|
||||
shareLog,
|
||||
close,
|
||||
isEditingImage,
|
||||
isFindOpen,
|
||||
fileContent,
|
||||
hasUnsavedChanges,
|
||||
revertChanges,
|
||||
toggleFind,
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex h-[750px] items-center justify-center overflow-hidden rounded-[20px] bg-black"
|
||||
>
|
||||
<div v-if="state.hasError" class="flex flex-col items-center justify-center gap-4">
|
||||
<TriangleAlertIcon class="size-8 text-red" />
|
||||
<p class="m-0 text-secondary">
|
||||
{{ state.errorMessage || formatMessage(messages.invalidImage) }}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imageRef"
|
||||
:src="imageObjectUrl"
|
||||
class="max-h-full max-w-full rounded-lg object-contain"
|
||||
:class="{ 'cursor-zoom-in': !zoomed, 'cursor-zoom-out': zoomed }"
|
||||
:alt="formatMessage(messages.viewedImageAlt)"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@click="toggleZoom"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isReady"
|
||||
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-2xl bg-surface-3/80 p-1.5 backdrop-blur-sm"
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.zoomIn)" @click="zoomIn">
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.zoomOut)" @click="zoomOut">
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.resetZoom)" @click="resetZoom">
|
||||
<span class="px-1 text-sm tabular-nums">{{ Math.round(scale * 100) }}%</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlertIcon, ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
invalidImage: {
|
||||
id: 'files.image_viewer.invalid_image',
|
||||
defaultMessage: 'Invalid or empty image file.',
|
||||
},
|
||||
viewedImageAlt: {
|
||||
id: 'files.image_viewer.viewed_image_alt',
|
||||
defaultMessage: 'Viewed image',
|
||||
},
|
||||
zoomIn: {
|
||||
id: 'files.image_viewer.zoom_in',
|
||||
defaultMessage: 'Zoom in',
|
||||
},
|
||||
zoomOut: {
|
||||
id: 'files.image_viewer.zoom_out',
|
||||
defaultMessage: 'Zoom out',
|
||||
},
|
||||
resetZoom: {
|
||||
id: 'files.image_viewer.reset_zoom',
|
||||
defaultMessage: 'Reset zoom',
|
||||
},
|
||||
imageTooLarge: {
|
||||
id: 'files.image_viewer.image_too_large',
|
||||
defaultMessage: 'Image too large to view (max {maxDimension}x{maxDimension} pixels)',
|
||||
},
|
||||
loadFailed: {
|
||||
id: 'files.image_viewer.load_failed',
|
||||
defaultMessage: 'Failed to load image',
|
||||
},
|
||||
})
|
||||
|
||||
const MAX_IMAGE_DIMENSION = 4096
|
||||
|
||||
const props = defineProps<{
|
||||
imageBlob: Blob
|
||||
}>()
|
||||
|
||||
const state = ref({
|
||||
isLoading: true,
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
})
|
||||
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const imageObjectUrl = ref('')
|
||||
const scale = ref(1)
|
||||
const zoomed = ref(false)
|
||||
|
||||
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
|
||||
|
||||
function updateImageUrl(blob: Blob) {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
imageObjectUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
function handleImageLoad() {
|
||||
const img = imageRef.value
|
||||
if (img && (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION)) {
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = formatMessage(messages.imageTooLarge, {
|
||||
maxDimension: MAX_IMAGE_DIMENSION,
|
||||
})
|
||||
}
|
||||
state.value.isLoading = false
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
state.value.isLoading = false
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = formatMessage(messages.loadFailed)
|
||||
}
|
||||
|
||||
function toggleZoom() {
|
||||
if (zoomed.value) {
|
||||
resetZoom()
|
||||
} else {
|
||||
scale.value = 2
|
||||
zoomed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale.value = Math.min(scale.value * 1.25, 5)
|
||||
zoomed.value = scale.value > 1
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale.value = Math.max(scale.value * 0.8, 0.1)
|
||||
zoomed.value = scale.value > 1
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
scale.value = 1
|
||||
zoomed.value = false
|
||||
}
|
||||
|
||||
watch(scale, (s) => {
|
||||
if (imageRef.value) {
|
||||
imageRef.value.style.transform = s === 1 ? '' : `scale(${s})`
|
||||
imageRef.value.style.transition = 'transform 0.2s ease-out'
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (!newBlob) return
|
||||
state.value.isLoading = true
|
||||
state.value.hasError = false
|
||||
scale.value = 1
|
||||
zoomed.value = false
|
||||
updateImageUrl(newBlob)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) updateImageUrl(props.imageBlob)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
formatMessage(messages.header, {
|
||||
type: formatFileItemType(formatMessage, type),
|
||||
})
|
||||
"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(fileValidationMessages.nameLabel)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="createInput"
|
||||
v-model="itemName"
|
||||
:placeholder="
|
||||
formatMessage(
|
||||
type === 'file' ? messages.placeholderFile : messages.placeholderDirectory,
|
||||
)
|
||||
"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-sm text-red">{{ error }}</div>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error && submitted" @click="handleSubmit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
{{
|
||||
formatMessage(messages.createButton, {
|
||||
type: formatFileItemType(formatMessage, type),
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages, formatFileItemType } from '#ui/utils/common-messages'
|
||||
|
||||
import { fileValidationMessages } from './file-validation-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.create-modal.header',
|
||||
defaultMessage: 'Create a {type}',
|
||||
},
|
||||
placeholderFile: {
|
||||
id: 'files.create-modal.placeholder-file',
|
||||
defaultMessage: 'e.g. config.yml',
|
||||
},
|
||||
placeholderDirectory: {
|
||||
id: 'files.create-modal.placeholder-directory',
|
||||
defaultMessage: 'e.g. my-folder',
|
||||
},
|
||||
createButton: {
|
||||
id: 'files.create-modal.create-button',
|
||||
defaultMessage: 'Create {type}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'file' | 'directory'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [name: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const createInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return formatMessage(fileValidationMessages.nameRequired)
|
||||
}
|
||||
if (props.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidFile)
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidDirectory)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('create', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
itemName.value = ''
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
createInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
fade="danger"
|
||||
:header="formatMessage(isBulk ? messages.bulkHeader : messages.header)"
|
||||
max-width="500px"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition
|
||||
v-if="symlinkTarget"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.symlinkWarningHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.symlinkWarningBody, { path: symlinkTarget }) }}
|
||||
</Admonition>
|
||||
<Admonition type="critical" class="md:min-w-[400px]">
|
||||
<template #header>{{
|
||||
isBulk
|
||||
? formatMessage(messages.deletingMultiple, { count: bulkCount })
|
||||
: formatMessage(messages.deletingName, { name: item?.name })
|
||||
}}</template>
|
||||
{{
|
||||
isBulk
|
||||
? formatMessage(messages.bulkWarning)
|
||||
: formatMessage(
|
||||
item?.type === 'directory'
|
||||
? messages.deleteFolderWarning
|
||||
: messages.deleteFileWarning,
|
||||
)
|
||||
}}
|
||||
</Admonition>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="handleSubmit">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.delete-modal.header',
|
||||
defaultMessage: 'Delete file',
|
||||
},
|
||||
bulkHeader: {
|
||||
id: 'files.delete-modal.bulk-header',
|
||||
defaultMessage: 'Delete multiple items',
|
||||
},
|
||||
deletingName: {
|
||||
id: 'files.delete-modal.deleting-name',
|
||||
defaultMessage: 'Deleting "{name}"',
|
||||
},
|
||||
deletingMultiple: {
|
||||
id: 'files.delete-modal.deleting-multiple',
|
||||
defaultMessage: 'Deleting {count} items',
|
||||
},
|
||||
deleteFileWarning: {
|
||||
id: 'files.delete-modal.warning.file',
|
||||
defaultMessage: 'This file will be permanently deleted. This action cannot be undone.',
|
||||
},
|
||||
deleteFolderWarning: {
|
||||
id: 'files.delete-modal.warning.folder',
|
||||
defaultMessage:
|
||||
'This folder and all its contents will be permanently deleted. This action cannot be undone.',
|
||||
},
|
||||
bulkWarning: {
|
||||
id: 'files.delete-modal.bulk-warning',
|
||||
defaultMessage: 'The selected items will be permanently deleted. This action cannot be undone.',
|
||||
},
|
||||
symlinkWarningHeader: {
|
||||
id: 'files.delete-modal.symlink-warning-header',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
symlinkWarningBody: {
|
||||
id: 'files.delete-modal.symlink-warning-body',
|
||||
defaultMessage:
|
||||
'You are modifying files in a shared instance linked to "{path}". Changes will affect the original instance.',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
symlinkTarget?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const isBulk = ref(false)
|
||||
const bulkCount = ref(0)
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('delete')
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
isBulk.value = false
|
||||
bulkCount.value = 0
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const showBulk = (count: number) => {
|
||||
isBulk.value = true
|
||||
bulkCount.value = count
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, showBulk, hide })
|
||||
</script>
|
||||
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
formatMessage(messages.header, {
|
||||
type: formatFileItemType(formatMessage, item?.type),
|
||||
})
|
||||
"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.currentLocation)
|
||||
}}</span>
|
||||
<span class="text-secondary">{{ `${currentPath}/${item?.name}`.replace('//', '/') }}</span>
|
||||
</div>
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.destinationPath)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="destinationInput"
|
||||
v-model="destination"
|
||||
:placeholder="formatMessage(messages.destinationPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleSubmit">
|
||||
<RightArrowIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.moveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages, formatFileItemType } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.move-modal.header',
|
||||
defaultMessage: 'Move {type}',
|
||||
},
|
||||
currentLocation: {
|
||||
id: 'files.move-modal.current-location',
|
||||
defaultMessage: 'Current location',
|
||||
},
|
||||
destinationPath: {
|
||||
id: 'files.move-modal.destination-path',
|
||||
defaultMessage: 'Destination path',
|
||||
},
|
||||
destinationPlaceholder: {
|
||||
id: 'files.move-modal.destination-placeholder',
|
||||
defaultMessage: 'e.g. /my-folder',
|
||||
},
|
||||
})
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
currentPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
move: [destination: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const destination = ref('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const path = destination.value.replace('//', '/')
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
emit('move', normalized)
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
destination.value = ''
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
destinationInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { name: item?.name })"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.newNameLabel) }}</span>
|
||||
<StyledInput ref="renameInput" v-model="itemName" wrapper-class="w-full" />
|
||||
<div v-if="submitted && error" class="text-sm text-red">{{ error }}</div>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error && submitted" @click="handleSubmit">
|
||||
<EditIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.renameButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
import { fileValidationMessages } from './file-validation-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.rename-modal.header',
|
||||
defaultMessage: 'Rename {name}',
|
||||
},
|
||||
newNameLabel: {
|
||||
id: 'files.rename-modal.new-name-label',
|
||||
defaultMessage: 'New name',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
rename: [newName: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const renameInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return formatMessage(fileValidationMessages.nameRequired)
|
||||
}
|
||||
if (props.item?.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidFile)
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidDirectory)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('rename', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = (item: { name: string; type: string }) => {
|
||||
itemName.value = item.name
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
renameInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="warning" :header="formatMessage(messages.header)" max-width="500px">
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.body) }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="handleDiscard">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.discard) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="handleSave">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.unsaved-changes-modal.header',
|
||||
defaultMessage: 'Unsaved changes',
|
||||
},
|
||||
body: {
|
||||
id: 'files.unsaved-changes-modal.body',
|
||||
defaultMessage:
|
||||
'You have unsaved changes that will be lost if you leave. Would you like to save before leaving?',
|
||||
},
|
||||
discard: {
|
||||
id: 'files.unsaved-changes-modal.discard',
|
||||
defaultMessage: 'Discard',
|
||||
},
|
||||
})
|
||||
|
||||
export type UnsavedChangesResult = 'cancel' | 'discard' | 'save'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
let resolvePromise: ((value: UnsavedChangesResult) => void) | null = null
|
||||
|
||||
function prompt(): Promise<UnsavedChangesResult> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
modal.value?.show()
|
||||
})
|
||||
}
|
||||
|
||||
function resolve(result: UnsavedChangesResult) {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(result)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
resolve('cancel')
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
resolve('discard')
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
resolve('save')
|
||||
}
|
||||
|
||||
defineExpose({ prompt })
|
||||
</script>
|
||||
@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :closable="true" no-padding>
|
||||
<div class="max-w-[500px]">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<Admonition type="warning" :header="formatMessage(messages.warningHeader)">
|
||||
<span>
|
||||
<template v-if="hasMany">
|
||||
{{ formatMessage(messages.overwriteManyWarning) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(messages.overwriteWarning, { count: files.length }) }}
|
||||
</template>
|
||||
</span>
|
||||
</Admonition>
|
||||
|
||||
<div v-if="files.length" class="flex gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<MinusIcon />
|
||||
{{ formatMessage(messages.overwrittenCount, { count: files.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="files.length"
|
||||
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
|
||||
>
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file"
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] items-center min-h-10 h-10 gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-between">
|
||||
<div class="w-[1px] h-2"></div>
|
||||
<MinusIcon class="text-red" />
|
||||
<div
|
||||
:class="index === files.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
|
||||
class="w-[1px] h-2 relative top-1"
|
||||
></div>
|
||||
</div>
|
||||
<span class="flex min-w-0 text-sm whitespace-nowrap overflow-hidden">
|
||||
<IntlFormatted :message-id="messages.overwriteFileLabel" :values="{ path: file }">
|
||||
<template #file-path="{ children }">
|
||||
<span
|
||||
v-tooltip="file"
|
||||
class="min-w-0 text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleProceed">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.overwriteButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, MinusIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.conflict-modal.header',
|
||||
defaultMessage: 'Extract summary',
|
||||
},
|
||||
warningHeader: {
|
||||
id: 'files.conflict-modal.warning-header',
|
||||
defaultMessage: 'Files will be overwritten',
|
||||
},
|
||||
overwriteManyWarning: {
|
||||
id: 'files.conflict-modal.overwrite-many-warning',
|
||||
defaultMessage:
|
||||
'Over 100 files will be overwritten if you proceed with extraction; here are some of them.',
|
||||
},
|
||||
overwriteWarning: {
|
||||
id: 'files.conflict-modal.overwrite-warning',
|
||||
defaultMessage:
|
||||
'The following {count} files already exist on your server, and will be overwritten if you proceed with extraction.',
|
||||
},
|
||||
overwrittenCount: {
|
||||
id: 'files.conflict-modal.overwritten-count',
|
||||
defaultMessage: '{count} overwritten',
|
||||
},
|
||||
overwriteFileLabel: {
|
||||
id: 'files.conflict-modal.overwrite-file-label',
|
||||
defaultMessage: 'Will overwrite <file-path>{path}</file-path>',
|
||||
},
|
||||
overwriteButton: {
|
||||
id: 'files.conflict-modal.overwrite-button',
|
||||
defaultMessage: 'Overwrite',
|
||||
},
|
||||
})
|
||||
|
||||
const path = ref('')
|
||||
const files = ref<string[]>([])
|
||||
|
||||
const emit = defineEmits<{
|
||||
proceed: [path: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const hasMany = computed(() => files.value.length > 100)
|
||||
|
||||
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||
path.value = zipPath
|
||||
files.value = conflictingFiles
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const handleProceed = () => {
|
||||
hide()
|
||||
emit('proceed', path.value)
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="cf ? formatMessage(messages.cfHeader) : formatMessage(messages.zipHeader)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 md:w-[700px]" @submit.prevent="handleSubmit">
|
||||
<!-- CurseForge stepper cards -->
|
||||
<div v-if="cf" class="flex gap-4">
|
||||
<div
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
class="flex flex-1 flex-col gap-2 overflow-clip rounded-[20px] bg-surface-2 p-3"
|
||||
>
|
||||
<span
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-4 font-medium text-contrast"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<div class="font-semibold leading-snug text-contrast">
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div class="text-sm leading-relaxed text-secondary">
|
||||
{{ step.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL input -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label v-if="cf" class="text-base font-semibold text-contrast">{{
|
||||
formatMessage(messages.enterLink)
|
||||
}}</label>
|
||||
<div v-else class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.zipDescription) }}
|
||||
</div>
|
||||
<StyledInput
|
||||
v-model="url"
|
||||
v-tooltip="props.disabled ? props.disabledTooltip : undefined"
|
||||
:icon="LinkIcon"
|
||||
type="url"
|
||||
:placeholder="
|
||||
cf
|
||||
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
|
||||
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
|
||||
"
|
||||
:disabled="submitted || props.disabled"
|
||||
:error="touched && !!error"
|
||||
autocomplete="off"
|
||||
@focus="touched = true"
|
||||
/>
|
||||
<div v-if="touched && error" class="text-xs text-red">{{ error }}</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex w-full items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" @click="hide">
|
||||
<XIcon />
|
||||
{{
|
||||
submitted
|
||||
? formatMessage(commonMessages.closeButton)
|
||||
: formatMessage(commonMessages.cancelButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="submitTooltip"
|
||||
:disabled="submitDisabled"
|
||||
type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<SpinnerIcon v-if="submitted" class="animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
submitted
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: formatMessage(messages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
LinkIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers/api-client'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
disabledTooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
cfHeader: {
|
||||
id: 'files.zip-url-modal.cf-header',
|
||||
defaultMessage: 'Install a CurseForge modpack',
|
||||
},
|
||||
zipHeader: {
|
||||
id: 'files.zip-url-modal.zip-header',
|
||||
defaultMessage: 'Uploading .zip contents from URL',
|
||||
},
|
||||
enterLink: {
|
||||
id: 'files.zip-url-modal.enter-link',
|
||||
defaultMessage: 'Enter link',
|
||||
},
|
||||
zipDescription: {
|
||||
id: 'files.zip-url-modal.zip-description',
|
||||
defaultMessage: 'Copy and paste the direct download URL of a .zip file.',
|
||||
},
|
||||
installButton: {
|
||||
id: 'files.zip-url-modal.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
stepFindTitle: {
|
||||
id: 'files.zip-url-modal.step-find-title',
|
||||
defaultMessage: 'Find the modpack',
|
||||
},
|
||||
stepFindDescription: {
|
||||
id: 'files.zip-url-modal.step-find-description',
|
||||
defaultMessage: 'Browse CurseForge and locate the modpack you want.',
|
||||
},
|
||||
stepSelectTitle: {
|
||||
id: 'files.zip-url-modal.step-select-title',
|
||||
defaultMessage: 'Select a version',
|
||||
},
|
||||
stepSelectDescription: {
|
||||
id: 'files.zip-url-modal.step-select-description',
|
||||
defaultMessage: 'Go to the "Files" tab and pick the version to install.',
|
||||
},
|
||||
stepCopyTitle: {
|
||||
id: 'files.zip-url-modal.step-copy-title',
|
||||
defaultMessage: 'Copy the URL',
|
||||
},
|
||||
stepCopyDescription: {
|
||||
id: 'files.zip-url-modal.step-copy-description',
|
||||
defaultMessage: 'Copy the version page URL and paste it below.',
|
||||
},
|
||||
errorUrlRequired: {
|
||||
id: 'files.zip-url-modal.error-url-required',
|
||||
defaultMessage: 'URL is required.',
|
||||
},
|
||||
errorCfUrl: {
|
||||
id: 'files.zip-url-modal.error-cf-url',
|
||||
defaultMessage: 'URL must be a CurseForge modpack version URL.',
|
||||
},
|
||||
errorUrlInvalid: {
|
||||
id: 'files.zip-url-modal.error-url-invalid',
|
||||
defaultMessage: 'URL must be valid.',
|
||||
},
|
||||
cfNotFoundTitle: {
|
||||
id: 'files.zip-url-modal.cf-not-found-title',
|
||||
defaultMessage: 'CurseForge modpack not found',
|
||||
},
|
||||
cfNotFoundText: {
|
||||
id: 'files.zip-url-modal.cf-not-found-text',
|
||||
defaultMessage: 'Could not find CurseForge modpack at that URL.',
|
||||
},
|
||||
installFailedTitle: {
|
||||
id: 'files.zip-url-modal.install-failed-title',
|
||||
defaultMessage: 'Installation failed',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'files.zip-url-modal.unknown-error',
|
||||
defaultMessage: 'An unknown error occurred',
|
||||
},
|
||||
})
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: SearchIcon,
|
||||
title: formatMessage(messages.stepFindTitle),
|
||||
description: formatMessage(messages.stepFindDescription),
|
||||
},
|
||||
{
|
||||
icon: FileTextIcon,
|
||||
title: formatMessage(messages.stepSelectTitle),
|
||||
description: formatMessage(messages.stepSelectDescription),
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
title: formatMessage(messages.stepCopyTitle),
|
||||
description: formatMessage(messages.stepCopyDescription),
|
||||
},
|
||||
]
|
||||
|
||||
const cf = ref(false)
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const url = ref('')
|
||||
const submitted = ref(false)
|
||||
const touched = ref(false)
|
||||
|
||||
const trimmedUrl = computed(() => url.value.trim())
|
||||
|
||||
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/
|
||||
|
||||
const error = computed(() => {
|
||||
if (trimmedUrl.value.length === 0) {
|
||||
return formatMessage(messages.errorUrlRequired)
|
||||
}
|
||||
if (cf.value && !regex.test(trimmedUrl.value)) {
|
||||
return formatMessage(messages.errorCfUrl)
|
||||
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
|
||||
return formatMessage(messages.errorUrlInvalid)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const submitDisabled = computed(() => submitted.value || props.disabled || !!error.value)
|
||||
const submitTooltip = computed(() => {
|
||||
if (props.disabled) return props.disabledTooltip
|
||||
return error.value || undefined
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
touched.value = true
|
||||
if (submitDisabled.value) return
|
||||
|
||||
submitted.value = true
|
||||
try {
|
||||
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
|
||||
|
||||
if (!cf.value || dry.modpack_name) {
|
||||
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
|
||||
hide()
|
||||
} else {
|
||||
submitted.value = false
|
||||
addNotification({
|
||||
title: formatMessage(messages.cfNotFoundTitle),
|
||||
text: formatMessage(messages.cfNotFoundText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
submitted.value = false
|
||||
console.error('Error installing:', err)
|
||||
addNotification({
|
||||
title: formatMessage(messages.installFailedTitle),
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.unknownError),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const show = (isCf: boolean) => {
|
||||
if (props.disabled) return
|
||||
|
||||
cf.value = isCf
|
||||
url.value = ''
|
||||
submitted.value = false
|
||||
touched.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
modal.value?.$el?.querySelector('input')?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@ -0,0 +1,22 @@
|
||||
import { defineMessages } from '#ui/composables/i18n'
|
||||
|
||||
export const fileValidationMessages = defineMessages({
|
||||
nameLabel: {
|
||||
id: 'files.validation.name-label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
nameRequired: {
|
||||
id: 'files.validation.name-required',
|
||||
defaultMessage: 'Name is required.',
|
||||
},
|
||||
nameInvalidFile: {
|
||||
id: 'files.validation.name-invalid-file',
|
||||
defaultMessage:
|
||||
'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.',
|
||||
},
|
||||
nameInvalidDirectory: {
|
||||
id: 'files.validation.name-invalid-directory',
|
||||
defaultMessage:
|
||||
'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.',
|
||||
},
|
||||
})
|
||||
@ -0,0 +1,120 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface FileDragData {
|
||||
name: string
|
||||
type: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const activeDrag = ref<FileDragData | null>(null)
|
||||
const dragTarget = ref<string | null>(null)
|
||||
const ghostEl = ref<HTMLElement | null>(null)
|
||||
const pointerStartX = ref(0)
|
||||
const pointerStartY = ref(0)
|
||||
const dragStarted = ref(false)
|
||||
|
||||
const DRAG_THRESHOLD = 5
|
||||
|
||||
export const fileDragData = activeDrag
|
||||
export const fileDragTarget = dragTarget
|
||||
export const fileDragActive = dragStarted
|
||||
|
||||
function createGhost(name: string): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.className =
|
||||
'fixed z-[99999] flex items-center max-w-[500px] gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none text-contrast font-bold truncate'
|
||||
el.textContent = name
|
||||
el.style.transform = 'translate(-50%, -100%)'
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
function findDropTarget(x: number, y: number): string | null {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
if (!el) return null
|
||||
const row = (el as HTMLElement).closest('[data-file-type="directory"]') as HTMLElement | null
|
||||
return row?.dataset.filePath ?? null
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!activeDrag.value) return
|
||||
|
||||
if (!dragStarted.value) {
|
||||
const dx = e.clientX - pointerStartX.value
|
||||
const dy = e.clientY - pointerStartY.value
|
||||
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return
|
||||
dragStarted.value = true
|
||||
ghostEl.value = createGhost(activeDrag.value.name)
|
||||
}
|
||||
|
||||
if (ghostEl.value) {
|
||||
ghostEl.value.style.left = `${e.clientX}px`
|
||||
ghostEl.value.style.top = `${e.clientY - 10}px`
|
||||
}
|
||||
|
||||
const target = findDropTarget(e.clientX, e.clientY)
|
||||
if (target !== dragTarget.value) {
|
||||
dragTarget.value = target
|
||||
}
|
||||
}
|
||||
|
||||
let clickSuppressed = false
|
||||
|
||||
export function wasRecentDrag(): boolean {
|
||||
return clickSuppressed
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
const wasDrag = dragStarted.value
|
||||
if (ghostEl.value) {
|
||||
ghostEl.value.remove()
|
||||
ghostEl.value = null
|
||||
}
|
||||
activeDrag.value = null
|
||||
dragTarget.value = null
|
||||
dragStarted.value = false
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
document.removeEventListener('pointercancel', onPointerCancel)
|
||||
if (wasDrag) {
|
||||
clickSuppressed = true
|
||||
requestAnimationFrame(() => {
|
||||
clickSuppressed = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let onDropCallback: ((source: FileDragData, destination: string) => void) | null = null
|
||||
|
||||
function onPointerCancel() {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (dragStarted.value && activeDrag.value && dragTarget.value) {
|
||||
const src = activeDrag.value
|
||||
const dest = dragTarget.value
|
||||
const isSelf = dest === src.path
|
||||
const isChild = src.type === 'directory' && dest.startsWith(src.path + '/')
|
||||
if (!isSelf && !isChild) {
|
||||
onDropCallback?.(src, dest)
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
export function startFileDrag(
|
||||
data: FileDragData,
|
||||
e: PointerEvent,
|
||||
onDrop: (source: FileDragData, destination: string) => void,
|
||||
) {
|
||||
activeDrag.value = data
|
||||
pointerStartX.value = e.clientX
|
||||
pointerStartY.value = e.clientY
|
||||
dragStarted.value = false
|
||||
onDropCallback = onDrop
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove)
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
document.addEventListener('pointercancel', onPointerCancel)
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { FileItem } from '../types'
|
||||
|
||||
export function useFileSearch(items: Ref<FileItem[]>) {
|
||||
const searchQuery = ref('')
|
||||
|
||||
const searchedItems = computed(() => {
|
||||
if (!searchQuery.value) return items.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return items.value.filter((item) => item.name.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
searchedItems,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { FileItem } from '../types'
|
||||
|
||||
export function useFileSelection(items: Ref<FileItem[]>) {
|
||||
const selectedItems = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleItemSelection(path: string) {
|
||||
const newSet = new Set(selectedItems.value)
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path)
|
||||
} else {
|
||||
newSet.add(path)
|
||||
}
|
||||
selectedItems.value = newSet
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedItems.value = new Set(items.value.map((i) => i.path))
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedItems.value = new Set()
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) {
|
||||
deselectAll()
|
||||
} else {
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
const allSelected = computed(
|
||||
() => items.value.length > 0 && selectedItems.value.size === items.value.length,
|
||||
)
|
||||
|
||||
const someSelected = computed(
|
||||
() => selectedItems.value.size > 0 && selectedItems.value.size < items.value.length,
|
||||
)
|
||||
|
||||
return {
|
||||
selectedItems,
|
||||
toggleItemSelection,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
toggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { FileItem, FileSortField, FileViewFilter } from '../types'
|
||||
|
||||
export function useFileSorting(items: Ref<FileItem[]>) {
|
||||
const sortField = ref<FileSortField>('name')
|
||||
const sortDesc = ref(false)
|
||||
const viewFilter = ref<FileViewFilter>('all')
|
||||
|
||||
function handleSort(field: FileSortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDesc.value = !sortDesc.value
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDesc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSort() {
|
||||
sortField.value = 'name'
|
||||
sortDesc.value = false
|
||||
viewFilter.value = 'all'
|
||||
}
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
let result = [...items.value]
|
||||
|
||||
switch (viewFilter.value) {
|
||||
case 'filesOnly':
|
||||
result = result.filter((item) => item.type !== 'directory')
|
||||
break
|
||||
case 'foldersOnly':
|
||||
result = result.filter((item) => item.type === 'directory')
|
||||
break
|
||||
}
|
||||
|
||||
function compareItems(a: FileItem, b: FileItem) {
|
||||
if (viewFilter.value === 'all') {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1
|
||||
}
|
||||
|
||||
switch (sortField.value) {
|
||||
case 'modified':
|
||||
return sortDesc.value ? a.modified - b.modified : b.modified - a.modified
|
||||
case 'created':
|
||||
return sortDesc.value ? a.created - b.created : b.created - a.created
|
||||
case 'size': {
|
||||
const aValue =
|
||||
a.type === 'directory'
|
||||
? a.count !== undefined
|
||||
? a.count
|
||||
: 0
|
||||
: a.size !== undefined
|
||||
? a.size
|
||||
: 0
|
||||
const bValue =
|
||||
b.type === 'directory'
|
||||
? b.count !== undefined
|
||||
? b.count
|
||||
: 0
|
||||
: b.size !== undefined
|
||||
? b.size
|
||||
: 0
|
||||
return sortDesc.value ? aValue - bValue : bValue - aValue
|
||||
}
|
||||
default:
|
||||
return sortDesc.value ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)
|
||||
}
|
||||
}
|
||||
|
||||
result.sort(compareItems)
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortDesc,
|
||||
viewFilter,
|
||||
sortedItems,
|
||||
handleSort,
|
||||
resetSort,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Operation } from '../types'
|
||||
|
||||
export function useFileUndoRedo(
|
||||
renameItem: (path: string, newName: string) => Promise<void>,
|
||||
moveItem: (source: string, destination: string) => Promise<void>,
|
||||
refresh: () => void,
|
||||
notify: (title: string, text: string, type: 'success' | 'error') => void,
|
||||
) {
|
||||
const operationHistory = ref<Operation[]>([])
|
||||
const redoStack = ref<Operation[]>([])
|
||||
|
||||
function recordOperation(op: Operation) {
|
||||
redoStack.value = []
|
||||
operationHistory.value.push(op)
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
const lastOperation = operationHistory.value.pop()
|
||||
if (!lastOperation) return
|
||||
|
||||
try {
|
||||
switch (lastOperation.type) {
|
||||
case 'move':
|
||||
await moveItem(
|
||||
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace('//', '/'),
|
||||
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace('//', '/'),
|
||||
)
|
||||
break
|
||||
case 'rename':
|
||||
await renameItem(
|
||||
`${lastOperation.path}/${lastOperation.newName}`.replace('//', '/'),
|
||||
lastOperation.oldName,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
redoStack.value.push(lastOperation)
|
||||
refresh()
|
||||
notify(
|
||||
`${lastOperation.type === 'move' ? 'Move' : 'Rename'} undone`,
|
||||
`${lastOperation.fileName} has been restored to its original ${lastOperation.type === 'move' ? 'location' : 'name'}`,
|
||||
'success',
|
||||
)
|
||||
} catch {
|
||||
notify('Undo failed', `Failed to undo the last ${lastOperation.type} operation`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function redo() {
|
||||
const lastOperation = redoStack.value.pop()
|
||||
if (!lastOperation) return
|
||||
|
||||
try {
|
||||
switch (lastOperation.type) {
|
||||
case 'move':
|
||||
await moveItem(
|
||||
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace('//', '/'),
|
||||
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace('//', '/'),
|
||||
)
|
||||
break
|
||||
case 'rename':
|
||||
await renameItem(
|
||||
`${lastOperation.path}/${lastOperation.oldName}`.replace('//', '/'),
|
||||
lastOperation.newName,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
operationHistory.value.push(lastOperation)
|
||||
refresh()
|
||||
notify(
|
||||
`${lastOperation.type === 'move' ? 'Move' : 'Rename'} redone`,
|
||||
`${lastOperation.fileName} has been ${lastOperation.type === 'move' ? 'moved' : 'renamed'} again`,
|
||||
'success',
|
||||
)
|
||||
} catch {
|
||||
notify('Redo failed', `Failed to redo the last ${lastOperation.type} operation`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault()
|
||||
undo()
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
operationHistory,
|
||||
redoStack,
|
||||
recordOperation,
|
||||
undo,
|
||||
redo,
|
||||
onKeydown,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export { useFileSearch } from './file-search'
|
||||
export { useFileSelection } from './file-selection'
|
||||
export { useFileSorting } from './file-sorting'
|
||||
export { useFileUndoRedo } from './file-undo-redo'
|
||||
3
packages/ui/src/layouts/shared/files-tab/index.ts
Normal file
3
packages/ui/src/layouts/shared/files-tab/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as FilePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export * from './types'
|
||||
721
packages/ui/src/layouts/shared/files-tab/layout.vue
Normal file
721
packages/ui/src/layouts/shared/files-tab/layout.vue
Normal file
@ -0,0 +1,721 @@
|
||||
<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"
|
||||
:disabled="isBusy"
|
||||
:disabled-tooltip="busyTooltip"
|
||||
/>
|
||||
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
|
||||
<FileMoveItemModal
|
||||
ref="moveItemModal"
|
||||
:item="selectedItem"
|
||||
:current-path="ctx.currentPath.value"
|
||||
@move="handleMoveItem"
|
||||
/>
|
||||
<FileDeleteItemModal
|
||||
ref="deleteItemModal"
|
||||
:item="selectedItem"
|
||||
:symlink-target="ctx.symlinkTarget?.value"
|
||||
@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>
|
||||
<div v-if="!(ctx.loading.value && items.length === 0)" class="contents">
|
||||
<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"
|
||||
:is-editor-find-open="fileEditorRef?.isFindOpen"
|
||||
: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"
|
||||
@unzip-from-url="showUnzipFromUrlModal"
|
||||
@refresh="ctx.refresh"
|
||||
@share="() => fileEditorRef?.shareLog()"
|
||||
@find="() => fileEditorRef?.toggleFind()"
|
||||
>
|
||||
<template #before-refresh>
|
||||
<slot name="before-refresh" />
|
||||
</template>
|
||||
</FileNavbar>
|
||||
|
||||
<div v-if="!isEditing">
|
||||
<div
|
||||
ref="fileUploadEl"
|
||||
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
>
|
||||
<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)"
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
v-tooltip="isBusy ? busyTooltip : undefined"
|
||||
:disabled="isBusy"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
HistoryIcon,
|
||||
PackageOpenIcon,
|
||||
RightArrowIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from '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 { canOpenInFileEditor, 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 { 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({
|
||||
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
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load the code editor:', error)
|
||||
})
|
||||
|
||||
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 fileUploadEl = ref<HTMLElement | null>(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 pendingBulkDeletePaths = ref<string[]>([])
|
||||
|
||||
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') {
|
||||
if (isBusy.value) return false
|
||||
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) {
|
||||
if (isBusy.value) return
|
||||
await ctx.createItem(name, newItemType.value)
|
||||
}
|
||||
|
||||
async function handleRenameItem(newName: string) {
|
||||
if (isBusy.value) return
|
||||
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) {
|
||||
if (isBusy.value) return
|
||||
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() {
|
||||
if (isBusy.value) return
|
||||
|
||||
if (pendingBulkDeletePaths.value.length > 0) {
|
||||
for (const path of pendingBulkDeletePaths.value) {
|
||||
const item = items.value.find((i) => i.path === path)
|
||||
if (item) {
|
||||
ctx.deleteItem(path, item.type === 'directory')
|
||||
}
|
||||
}
|
||||
pendingBulkDeletePaths.value = []
|
||||
deselectAll()
|
||||
return
|
||||
}
|
||||
|
||||
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 (isBusy.value) return
|
||||
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
|
||||
pendingBulkDeletePaths.value = []
|
||||
selectedItem.value = item
|
||||
deleteItemModal.value?.show()
|
||||
}
|
||||
|
||||
function showBulkDeleteModal() {
|
||||
if (isBusy.value) return
|
||||
if (selectedItems.value.size === 0) return
|
||||
|
||||
pendingBulkDeletePaths.value = Array.from(selectedItems.value)
|
||||
deleteItemModal.value?.showBulk(pendingBulkDeletePaths.value.length)
|
||||
}
|
||||
|
||||
// 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 if (canOpenInFileEditor(item.name)) {
|
||||
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 additionalOptions = ctx.getAdditionalMenuOptions?.(item) ?? []
|
||||
const hasAdditionalOptions = additionalOptions.some((option) => option.shown !== false)
|
||||
|
||||
const options: FileContextMenuOption[] = [
|
||||
...additionalOptions,
|
||||
{ divider: true, shown: hasAdditionalOptions },
|
||||
{
|
||||
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>
|
||||
@ -0,0 +1,73 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type {
|
||||
EditingFile,
|
||||
ExtractDryRunResult,
|
||||
FileContextMenuOption,
|
||||
FileItem,
|
||||
FileOperation,
|
||||
} from '../types'
|
||||
|
||||
export interface FileManagerContext {
|
||||
items: Ref<FileItem[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<Error | null>
|
||||
|
||||
currentPath: Ref<string>
|
||||
navigateTo: (path: string) => void
|
||||
|
||||
editingFile: Ref<EditingFile | null>
|
||||
startEditing: (file: EditingFile) => void
|
||||
stopEditing: () => void
|
||||
|
||||
createItem: (name: string, type: 'file' | 'directory') => Promise<void>
|
||||
renameItem: (path: string, newName: string) => Promise<void>
|
||||
moveItem: (source: string, destination: string) => Promise<void>
|
||||
deleteItem: (path: string, recursive: boolean) => Promise<void>
|
||||
|
||||
readFile: (path: string) => Promise<string>
|
||||
readFileAsBlob: (path: string) => Promise<Blob>
|
||||
writeFile: (path: string, content: string) => Promise<void>
|
||||
downloadFile: (path: string, fileName: string) => Promise<void>
|
||||
|
||||
refresh: () => void
|
||||
|
||||
isBusy?: Ref<boolean> | ComputedRef<boolean>
|
||||
busyTooltip?: Ref<string | undefined> | ComputedRef<string | undefined>
|
||||
busyWarning?: Ref<string | null> | ComputedRef<string | null>
|
||||
|
||||
extractFile?: (
|
||||
path: string,
|
||||
override: boolean,
|
||||
dry: boolean,
|
||||
) => Promise<ExtractDryRunResult | void>
|
||||
activeOperations?: Ref<FileOperation[]> | ComputedRef<FileOperation[]>
|
||||
dismissOperation?: (id: string, action: 'dismiss' | 'cancel') => void
|
||||
|
||||
prefetchDirectory?: (path: string) => void
|
||||
prefetchFile?: (path: string) => void
|
||||
|
||||
showInstallFromUrl?: boolean
|
||||
basePath?: Ref<string> | ComputedRef<string>
|
||||
openInFolder?: (path: string) => void
|
||||
getAdditionalMenuOptions?: (
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'>,
|
||||
) => FileContextMenuOption[]
|
||||
|
||||
downloadButtonLabel?: string
|
||||
uploadingLabel?: (completed: number, total: number) => string
|
||||
|
||||
canRestart?: boolean
|
||||
restartServer?: () => Promise<void>
|
||||
canShareLog?: boolean
|
||||
shareLogs?: (content: string) => Promise<void>
|
||||
|
||||
symlinkTarget?: Ref<string | null | undefined> | ComputedRef<string | null | undefined>
|
||||
}
|
||||
|
||||
export const [injectFileManager, provideFileManager] = createContext<FileManagerContext>(
|
||||
'FilePageLayout',
|
||||
'fileManagerContext',
|
||||
)
|
||||
@ -0,0 +1,2 @@
|
||||
export type { FileManagerContext } from './file-manager'
|
||||
export { injectFileManager, provideFileManager } from './file-manager'
|
||||
73
packages/ui/src/layouts/shared/files-tab/types.ts
Normal file
73
packages/ui/src/layouts/shared/files-tab/types.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface FileItem {
|
||||
name: string
|
||||
type: 'file' | 'directory' | 'symlink'
|
||||
path: string
|
||||
modified: number
|
||||
created: number
|
||||
size?: number
|
||||
count?: number
|
||||
target?: string
|
||||
}
|
||||
|
||||
export interface EditingFile {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type FileSortField = 'name' | 'size' | 'created' | 'modified'
|
||||
|
||||
export type FileViewFilter = 'all' | 'filesOnly' | 'foldersOnly'
|
||||
|
||||
export type FileContextMenuOption =
|
||||
| {
|
||||
id: string
|
||||
label?: string
|
||||
icon?: Component
|
||||
action?: () => void
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
|
||||
shown?: boolean
|
||||
}
|
||||
| { divider: true; shown?: boolean }
|
||||
|
||||
export interface FileOperation {
|
||||
id?: string
|
||||
op: string
|
||||
src: string
|
||||
state: string
|
||||
progress?: number
|
||||
bytes_processed?: number
|
||||
files_processed?: number
|
||||
current_file?: string
|
||||
}
|
||||
|
||||
export interface UndoableOperation {
|
||||
type: 'move' | 'rename'
|
||||
itemType: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export interface MoveOperation extends UndoableOperation {
|
||||
type: 'move'
|
||||
sourcePath: string
|
||||
destinationPath: string
|
||||
}
|
||||
|
||||
export interface RenameOperation extends UndoableOperation {
|
||||
type: 'rename'
|
||||
path: string
|
||||
oldName: string
|
||||
newName: string
|
||||
}
|
||||
|
||||
export type Operation = MoveOperation | RenameOperation
|
||||
|
||||
export interface ExtractDryRunResult {
|
||||
modpack_name: string | null
|
||||
conflicting_files: string[]
|
||||
}
|
||||
|
||||
export type { UploadState } from '@modrinth/api-client'
|
||||
9
packages/ui/src/layouts/shared/files-tab/utils.ts
Normal file
9
packages/ui/src/layouts/shared/files-tab/utils.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function joinDisplayPath(basePath: string | undefined, itemPath: string) {
|
||||
if (!basePath) return itemPath
|
||||
|
||||
const separator = basePath.includes('\\') ? '\\' : '/'
|
||||
const path = itemPath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, separator)
|
||||
const base = basePath.replace(/[\\/]+$/, '')
|
||||
|
||||
return path ? `${base}${separator}${path}` : basePath
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user