feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 15:38:08 -06:00
committed by GitHub
parent a2a97d1313
commit 693a371d61
278 changed files with 15974 additions and 12608 deletions

View File

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

View File

@@ -0,0 +1,311 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ComputedRef, Ref, ShallowRef } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDebugLogger } from '#ui/composables/debug-logger'
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
import { useSearch } from '#ui/utils/search'
import { useServerSearch } from '#ui/utils/server-search'
import type { BrowseSearchResponse } from '../types'
export interface UseBrowseSearchOptions {
projectType: Ref<string>
tags: Ref<{
gameVersions: Labrinth.Tags.v2.GameVersion[]
loaders: Labrinth.Tags.v2.Loader[]
categories: Labrinth.Tags.v2.Category[]
}>
providedFilters?: ComputedRef<FilterValue[]>
search: (params: string) => Promise<BrowseSearchResponse>
persistentQueryParams: string[]
getExtraQueryParams?: () => Record<string, string | undefined>
maxResultsOptions?: ComputedRef<number[]>
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
}
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' | 'grid'>
deprioritizedTags: ComputedRef<string[]>
excludeLoaders: ComputedRef<boolean>
refreshSearch: () => Promise<void>
setPage: (page: number) => Promise<void>
clearSearch: () => void
onFilterChange: () => void
}
const LOADER_FILTER_TYPES = [
'mod_loader',
'plugin_loader',
'modpack_loader',
'shader_loader',
'plugin_platform',
] as const
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(() => []))
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags: options.tags, query, maxResults, currentPage })
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' | 'grid'>(() =>
effectiveDisplayMode.value === 'grid' || effectiveDisplayMode.value === 'gallery'
? 'grid'
: '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]),
) || ['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 loading = ref(true)
const projectHits = shallowRef<BrowseSearchResponse['projectHits']>([])
const serverHits = shallowRef<BrowseSearchResponse['serverHits']>([])
const totalHits = ref(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
watch(effectiveRequestParams, (newVal, oldVal) => {
debug('effectiveRequestParams changed', {
from: oldVal?.substring(0, 80),
to: newVal?.substring(0, 80),
})
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
refreshSearch()
}, 200)
})
async function refreshSearch() {
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)
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
debug('refreshSearch complete', {
version,
hits: response.total_hits,
projectHits: response.projectHits.length,
serverHits: response.serverHits.length,
})
updateUrlParams()
loading.value = false
} catch (err) {
debug('refreshSearch error', err)
console.error('Browse search error:', err)
if (version === searchVersion) {
loading.value = false
}
}
}
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)) {
if (value !== undefined) {
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 })
currentSortType.value = { display: 'Relevance', name: 'relevance' }
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,63 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon, MinecraftServerIcon } from '@modrinth/assets'
import { computed } 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 { injectBrowseManager } from './providers/browse-manager'
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 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 ?? MinecraftServerIcon
})
</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>
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
{{ installContext.warning }}
</Admonition>
</template>
</template>

View File

@@ -0,0 +1,6 @@
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,262 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { computed, 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 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 type { SortType } from '#ui/utils/search'
import BrowseInstallHeader from './header.vue'
import { injectBrowseManager } from './providers/browse-manager'
const ctx = injectBrowseManager()
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
ctx.effectiveSortTypes.value.map((st) => ({
value: st,
label: st.display,
})),
)
const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
(ctx.maxResultsOptions?.value ?? [5, 10, 15, 20, 50, 100]).map((n) => ({
value: n,
label: String(n),
})),
)
</script>
<template>
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
<BrowseInstallHeader />
</template>
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<StyledInput
v-model="ctx.query.value"
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="`Search ${ctx.projectType.value}s...`"
clearable
wrapper-class="w-full"
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
@clear="ctx.clearSearch()"
/>
<div class="flex flex-wrap items-center gap-2">
<Combobox
:model-value="ctx.effectiveCurrentSortType.value"
:options="sortOptions"
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[16rem]'"
placeholder="Sort by"
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">Sort by:</span>
</template>
</Combobox>
<Combobox
:model-value="ctx.maxResults.value"
:options="maxResultsOptions"
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[9rem]'"
placeholder="View"
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">View:</span>
</template>
</Combobox>
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
<ButtonStyled>
<button @click="ctx.filtersMenuOpen.value = true">Filter results...</button>
</ButtonStyled>
</div>
<ButtonStyled v-if="ctx.cycleDisplayMode" circular>
<button @click="ctx.cycleDisplayMode!()">
<slot name="display-mode-icon" />
</button>
</ButtonStyled>
<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="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="ctx.currentFilters.value"
:filters="ctx.filters.value.filter((f) => f.display !== 'none')"
:provided-filters="ctx.providedFilters?.value ?? []"
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-message="lockedMessages?.providedBy"
/>
<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">
You are currently offline. Connect to the internet to browse Modrinth!
</section>
<section
v-else-if="
ctx.isServerType.value
? ctx.serverHits.value.length === 0
: ctx.projectHits.value.length === 0
"
class="offline"
>
<p>No results found for your query!</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" #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"
:circular="action.circular"
>
<button
v-tooltip="action.tooltip"
:disabled="action.disabled"
@click.stop="action.onClick"
>
<component :is="action.icon" />
<template v-if="!action.circular">{{ action.label }}</template>
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
<template v-else>
<ProjectCard
v-for="result in ctx.projectHits.value"
:key="result.project_id"
:link="ctx.getProjectLink(result)"
:title="result.title"
:icon-url="result.icon_url"
:author="{
name: result.author,
link:
ctx.variant === 'web'
? `/user/${result.author}`
: `https://modrinth.com/user/${result.author}`,
}"
: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"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
: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" #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"
:circular="action.circular"
>
<button
v-tooltip="action.tooltip"
:disabled="action.disabled"
@click.stop="action.onClick"
>
<component :is="action.icon" />
<template v-if="!action.circular">{{ action.label }}</template>
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
</ProjectCardList>
<div :class="ctx.variant === 'web' ? 'pagination-after' : 'flex justify-end'">
<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" />
</template>

View File

@@ -0,0 +1,106 @@
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 } from '#ui/utils/search'
import type {
BrowseInstallContext,
BrowseSearchResponse,
CardAction,
ServerModpackContent,
} from '../types'
export interface BrowseManagerContext {
tags: Ref<{
gameVersions: Labrinth.Tags.v2.GameVersion[]
loaders: Labrinth.Tags.v2.Loader[]
categories: Labrinth.Tags.v2.Category[]
}>
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 }[]>
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>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
cycleDisplayMode?: () => 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,177 @@
<script setup lang="ts">
import { InfoIcon, XIcon } from '@modrinth/assets'
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 { injectBrowseManager } from './providers/browse-manager'
const ctx = injectBrowseManager()
const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
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-bg-raised'
})
const buttonClass = computed(() => {
if (isApp.value) {
return 'button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg'
}
return 'button-animation flex flex-col gap-1 px-6 py-4 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 getFilterOpenByDefault(filterId: string): boolean {
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) {
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">Filters</h3>
<ButtonStyled circular>
<button @click="closeFiltersMenu">
<XIcon />
</button>
</ButtonStyled>
</div>
<div
v-if="ctx.showHideInstalled?.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'
"
>
<Checkbox
v-model="ctx.hideInstalled!.value"
:label="ctx.hideInstalledLabel?.value ?? 'Hide installed content'"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
/>
</div>
<template v-if="ctx.isServerType.value">
<SearchSidebarFilter
v-for="filterType in ctx.serverFilterTypes.value.filter((f) => f.options.length > 0)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="ctx.serverCurrentFilters.value"
v-model:toggled-groups="ctx.serverToggledGroups.value"
:provided-filters="[]"
:filter-type="filterType"
: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-lg'">
{{ filterType.formatted_name }}
</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in ctx.filters.value.filter((f) => f.display !== 'none')"
: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"
:class="filterClass"
:button-class="buttonClass"
:content-class="contentClass"
:inner-panel-class="innerPanelClass"
:open-by-default="getFilterOpenByDefault(filter.id)"
>
<template #header>
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg'">
{{ 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,46 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export interface BrowseSearchResponse {
projectHits: (Labrinth.Search.v2.ResultSearchProject & {
installed?: boolean
installing?: boolean
})[]
serverHits: Labrinth.Search.v3.ResultSearchProject[]
total_hits: number
per_page: number
}
export interface BrowseInstallContext {
name: string
loader: string
gameVersion: string
serverId?: string | null
upstream?: { project_id?: string | null } | null
iconSrc?: string | null
isMedal?: boolean
backUrl: string | RouteLocationRaw
backLabel: string
heading: string
warning?: string
}
export interface CardAction {
key: string
label: string
icon: Component
disabled?: boolean
color?: 'brand' | 'red'
type?: 'standard' | 'outlined' | 'transparent'
circular?: boolean
tooltip?: string
onClick: () => void | Promise<void>
}
export interface ServerModpackContent {
name: string
icon?: string
onclick?: () => void
showCustomModpackTooltip: boolean
}