feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View 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'

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,2 @@
export * from './install-logic'
export * from './use-browse-search'

View File

@ -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}.`,
)
}

View File

@ -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,
}
}

View 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>

View 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'

View 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>

View File

@ -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',
)

View File

@ -0,0 +1 @@
export * from './browse-manager'

View 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>

View 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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 }
}

View File

@ -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'

View File

@ -0,0 +1,648 @@
import type { LogLevel } from '../types'
/**
* 日志高亮引擎 — 移植自 LogShare-Web-UI (src/lib/logParser.worker.ts)
*
* 逐行正则 + Trie 预筛 + LRU 缓存,输出 HTMLspan + 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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}
const RE_HTML_ESCAPE = /[&<>"']/g
const RE_MARK_OPEN = /&lt;mark&gt;/gi
const RE_MARK_CLOSE = /&lt;\/mark&gt;/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 → HTMLspan + 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()
}

View File

@ -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
}

View 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'

View 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)
})

View 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('')
}

View 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>

View 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.',
},
})

View File

@ -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',
)

View File

@ -0,0 +1 @@
export * from './console-manager'

View 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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
})
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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 }
}

View File

@ -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'] },
)
})

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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 }
}

View File

@ -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'],
)
})

View File

@ -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)
}
})
}

View File

@ -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()
}
})

View File

@ -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.
}
}

View File

@ -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,
}
}

View File

@ -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'

View File

@ -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 }
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View 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'

File diff suppressed because it is too large Load Diff

View File

@ -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',
)

View File

@ -0,0 +1 @@
export * from './content-manager'

View 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
}

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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.',
},
})

View File

@ -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)
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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'

View File

@ -0,0 +1,3 @@
export { default as FilePageLayout } from './layout.vue'
export * from './providers'
export * from './types'

View 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>

View File

@ -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',
)

View File

@ -0,0 +1,2 @@
export type { FileManagerContext } from './file-manager'
export { injectFileManager, provideFileManager } from './file-manager'

View 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'

View 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