feat: clean up browse shared layout logic + introduce queuing (#6030)

* feat: clean up edge case behaviour and add queued to install logic

* fix: remove version choice modal

* feat: queued flow

* feat: standardize headers in app on proj pages

* fix: clear btn

* feat: installing floating popup

* fix: lint

* fix: onboarding/reset logic change for modpacks

* qa: big ol qa

* fix: lint

* fix: lint

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-05-09 20:01:23 +01:00
committed by GitHub
parent 671f6d264a
commit a79b8e0777
40 changed files with 3726 additions and 664 deletions

View File

@@ -0,0 +1,137 @@
<template>
<FloatingActionBar
:shown="shown"
:aria-label="formatMessage(messages.ariaLabel)"
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"
:style="{ width: `${iconStackWidth}px` }"
aria-hidden="true"
>
<div
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"
/>
</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: `${visibleProjects.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
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="green">
<button type="button" :disabled="isInstallingSelected" @click="installSelected">
<PlusIcon />
{{ installButtonText }}
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
</template>
<script setup lang="ts">
import { PlusIcon } from '@modrinth/assets'
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 } 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}}',
},
})
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 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(() =>
formatMessage(messages.installButton, { count: selectedCount.value }),
)
function clearSelected() {
if (isInstallingSelected.value) return
void (installContext.value?.clearSelected ?? installContext.value?.clearQueued)?.()
}
function installSelected() {
if (isInstallingSelected.value) return
void installContext.value?.installSelected?.()
}
</script>
<style scoped>
:deep(.selected-project-avatar) {
background-color: var(--color-button-bg);
}
</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

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

View File

@@ -0,0 +1,540 @@
import type { Labrinth } from '@modrinth/api-client'
import type { FilterValue } from '#ui/utils/search'
export type BrowseInstallContentType = 'modpack' | 'mod' | 'plugin' | 'datapack'
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
}
/**
* 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
}
/**
* 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>
onError?: (error: unknown, plan: BrowseInstallPlan<TProject>) => void
onProgress?: (
completed: number,
total: number,
plan: BrowseInstallPlan<TProject>,
) => void | Promise<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>>
}
interface InstallCandidate {
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
}
/**
* 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']
return []
}
/**
* 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 shouldUseTargetRuntime = contentType !== 'modpack'
return normalizeInstallPreferences({
gameVersions: gameVersion && shouldUseTargetRuntime ? [gameVersion] : undefined,
loaders: loader && shouldUseTargetRuntime ? [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,
contentType: string,
) {
return [...versions]
.filter((version) => versionMatchesPreferences(version, preferences, contentType))
.sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime())[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,
options.contentType,
)
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,
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)
} catch (error) {
failedPlans.set(plan.projectId, plan)
onError?.(error, plan)
} finally {
completed++
await onProgress?.(completed, queuedPlans.length, plan)
}
}
queue.set(failedPlans)
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,
contentType: string,
) {
const gameVersionMatches =
!preferences.gameVersions?.length ||
version.game_versions.some((gameVersion) => preferences.gameVersions?.includes(gameVersion))
if (!gameVersionMatches) return false
if (contentType === 'datapack') return true
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 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

@@ -1,22 +1,29 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon, MinecraftServerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { 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 ContentPageHeader from '#ui/components/base/ContentPageHeader.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 ctx = injectBrowseManager()
const router = useRouter()
const installContext = computed(() => ctx.installContext?.value ?? null)
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)
@@ -27,35 +34,97 @@ const { image: fetchedIcon } = useServerImage(serverId, upstream, {
const iconSrc = computed(() => {
if (installContext.value?.isMedal) return MEDAL_ICON_URL
return fetchedIcon.value ?? installContext.value?.iconSrc ?? MinecraftServerIcon
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
})
const metadataItems = computed(() => {
const context = installContext.value
if (!context) return []
return [
context.heading,
context.gameVersion ? `MC ${context.gameVersion}` : '',
context.loader ? formatLoaderLabel(context.loader) : '',
].filter(Boolean)
})
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) {
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">
<ContentPageHeader class="mb-2">
<template #icon>
<Avatar :src="iconSrc" size="64px" />
</template>
<template #title>
{{ installContext.name }}
</template>
<template #summary>
<span class="flex items-center gap-2 text-sm font-semibold text-secondary">
<GameIcon class="h-5 w-5 text-secondary" />
{{ formatLoaderLabel(installContext.loader) }} {{ installContext.gameVersion }}
</span>
</template>
<template #actions>
<ButtonStyled>
<button @click="router.push(installContext.backUrl)">
<LeftArrowIcon />
{{ installContext.backLabel }}
</button>
</ButtonStyled>
</template>
</ContentPageHeader>
<h1 class="m-0 mb-1 text-xl font-extrabold">{{ installContext.heading }}</h1>
<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" />
<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="metadataItems.length"
class="flex flex-wrap items-center gap-2 text-base font-medium leading-6 text-primary"
>
<template v-for="(item, index) in metadataItems" :key="item">
<span
v-if="index > 0"
class="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"
/>
<span>{{ item }}</span>
</template>
</div>
</div>
</div>
</div>
</div>
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
{{ installContext.warning }}
</Admonition>

View File

@@ -1,3 +1,4 @@
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'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { computed, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
@@ -12,13 +12,23 @@ 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 } 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 sortOptions = computed<ComboboxOption<SortType>[]>(() =>
ctx.effectiveSortTypes.value.map((st) => ({
@@ -33,12 +43,43 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
label: String(n),
})),
)
const messages = defineMessages({
searchPlaceholder: {
id: 'browse.search.placeholder',
defaultMessage:
'Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} server {servers} other {projects}}...',
},
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!',
},
})
</script>
<template>
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
<BrowseInstallHeader />
<div
ref="stickyInstallHeaderRef"
class="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'" />
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
@@ -47,7 +88,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="`Search ${ctx.projectType.value}s...`"
:placeholder="formatMessage(messages.searchPlaceholder, { projectType: ctx.projectType.value })"
clearable
wrapper-class="w-full"
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
@@ -62,7 +103,9 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">Sort by:</span>
<span class="font-semibold text-primary">{{
formatMessage(commonMessages.sortByLabel)
}}</span>
</template>
</Combobox>
@@ -70,17 +113,19 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:model-value="ctx.maxResults.value"
:options="maxResultsOptions"
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[9rem]'"
placeholder="View"
:placeholder="formatMessage(commonMessages.viewLabel)"
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">View:</span>
<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">Filter results...</button>
<button @click="ctx.filtersMenuOpen.value = true">
{{ formatMessage(messages.filterResults) }}
</button>
</ButtonStyled>
</div>
@@ -119,7 +164,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
</section>
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
{{ formatMessage(messages.offline) }}
</section>
<section
v-else-if="
@@ -129,7 +174,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
"
class="offline"
>
<p>No results found for your query!</p>
<p>{{ formatMessage(messages.noResults) }}</p>
</section>
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">

View File

@@ -66,6 +66,9 @@ export interface BrowseManagerContext {
hideInstalled?: Ref<boolean>
showHideInstalled?: ComputedRef<boolean>
hideInstalledLabel?: ComputedRef<string>
hideSelected?: Ref<boolean>
showHideSelected?: ComputedRef<boolean>
hideSelectedLabel?: ComputedRef<string>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>

View File

@@ -5,10 +5,13 @@ import { computed, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.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))
@@ -80,7 +83,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
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">Filters</h3>
<h3 class="m-0 text-lg text-contrast">{{ formatMessage(commonMessages.filtersLabel) }}</h3>
<ButtonStyled circular>
<button @click="closeFiltersMenu">
<XIcon />
@@ -89,16 +92,29 @@ function getFilterOpenByDefault(filterId: string): boolean {
</div>
<div
v-if="ctx.showHideInstalled?.value"
v-if="ctx.showHideInstalled?.value || ctx.showHideSelected?.value"
:class="
isApp
? 'border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
: 'card-shadow rounded-2xl bg-bg-raised p-4'
? '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 p-4'
"
>
<Checkbox
v-if="ctx.showHideInstalled?.value"
v-model="ctx.hideInstalled!.value"
:label="ctx.hideInstalledLabel?.value ?? 'Hide already installed content'"
:label="
ctx.hideInstalledLabel?.value ?? formatMessage(commonMessages.hideInstalledContentLabel)
"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
/>
<Checkbox
v-if="ctx.showHideSelected?.value"
v-model="ctx.hideSelected!.value"
:label="
ctx.hideSelectedLabel?.value ?? formatMessage(commonMessages.hideSelectedContentLabel)
"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop

View File

@@ -12,6 +12,12 @@ export interface BrowseSearchResponse {
per_page: number
}
export interface BrowseSelectedProject {
id: string
name: string
iconUrl?: string | null
}
export interface BrowseInstallContext {
name: string
loader: string
@@ -24,6 +30,19 @@ export interface BrowseInstallContext {
backLabel: string
heading: string
warning?: string
queuedCount?: number
queuedLabel?: string
clearQueued?: () => void | Promise<void>
onBack?: () => boolean | void | Promise<boolean | void>
selectedProjects?: BrowseSelectedProject[]
isInstallingSelected?: boolean
installProgress?: {
completed: number
total: number
}
clearSelected?: () => void | Promise<void>
discardSelectedAndBack?: () => void | Promise<void>
installSelected?: () => boolean | void | Promise<boolean | void>
}
export interface CardAction {
@@ -32,7 +51,7 @@ export interface CardAction {
icon: Component
iconClass?: string
disabled?: boolean
color?: 'brand' | 'red'
color?: 'brand' | 'red' | 'green'
type?: 'standard' | 'outlined' | 'transparent'
circular?: boolean
tooltip?: string