1
0

Refactor search page, migrate to /discover/ (#4862)

This commit is contained in:
Prospector
2025-12-09 14:25:45 -08:00
committed by GitHub
parent 251e89fe5a
commit 1d64b2e22a
22 changed files with 1252 additions and 973 deletions

View File

@@ -7,7 +7,7 @@ import { consola } from 'consola'
import { promises as fs } from 'fs'
import { globIterate } from 'glob'
import { defineNuxtConfig } from 'nuxt/config'
import { basename, relative, resolve } from 'pathe'
import { basename, relative } from 'pathe'
import svgLoader from 'vite-svg-loader'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
@@ -176,23 +176,6 @@ export default defineNuxtConfig({
console.log('Tags generated!')
},
'pages:extend'(routes) {
routes.splice(
routes.findIndex((x) => x.name === 'search-searchProjectType'),
1,
)
const types = ['mods', 'modpacks', 'plugins', 'resourcepacks', 'shaders', 'datapacks']
types.forEach((type) =>
routes.push({
name: `search-${type}`,
path: `/${type}`,
file: resolve(__dirname, 'src/pages/search/[searchProjectType].vue'),
children: [],
}),
)
},
async 'vintl:extendOptions'(opts) {
opts.locales ??= []

View File

@@ -40,6 +40,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
hideRussiaCensorshipBanner: false,
serverDiscovery: false,
// advancedRendering: true,
// externalLinksNewTab: true,
// notUsingBlockers: false,

View File

@@ -1,10 +1,12 @@
import type { ISO3166, Labrinth } from '@modrinth/api-client'
import type { DisplayProjectType } from '@modrinth/utils'
import generatedState from '~/generated/state.json'
import type { DisplayMode } from '~/plugins/cosmetics'
export interface ProjectType {
actual: string
id: string
id: DisplayProjectType
display: string
}
@@ -25,7 +27,7 @@ export interface GeneratedState extends Labrinth.State.GeneratedState {
// Additional runtime-defined fields not from the API
projectTypes: ProjectType[]
loaderData: LoaderData
projectViewModes: string[]
projectViewModes: DisplayMode[]
approvedStatuses: string[]
rejectedStatuses: string[]
staffRoles: string[]

View File

@@ -237,12 +237,12 @@
<template v-if="flags.projectTypesPrimaryNav">
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-mods' || route.path.startsWith('/mod/')"
:highlighted="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
:highlighted-style="
route.name === 'search-mods' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-mods' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/mods">
<nuxt-link to="/discover/mods">
<BoxIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.mod) }}
</nuxt-link>
@@ -250,61 +250,63 @@
<ButtonStyled
type="transparent"
:highlighted="
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
"
:highlighted-style="
route.name === 'search-resourcepacks' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-resourcepacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/resourcepacks">
<nuxt-link to="/discover/resourcepacks">
<PaintbrushIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.resourcepack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
:highlighted="
route.name === 'discover-datapacks' || route.path.startsWith('/datapack/')
"
:highlighted-style="
route.name === 'search-datapacks' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-datapacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/datapacks">
<nuxt-link to="/discover/datapacks">
<BracesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
:highlighted="route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')"
:highlighted-style="
route.name === 'search-modpacks' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-modpacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/modpacks">
<nuxt-link to="/discover/modpacks">
<PackageOpenIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
:highlighted="route.name === 'discover-shaders' || route.path.startsWith('/shader/')"
:highlighted-style="
route.name === 'search-shaders' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-shaders' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/shaders">
<nuxt-link to="/discover/shaders">
<GlassesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
:highlighted="route.name === 'discover-plugins' || route.path.startsWith('/plugin/')"
:highlighted-style="
route.name === 'search-plugins' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-plugins' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/plugins">
<nuxt-link to="/discover/plugins">
<PlugIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.plugin) }}
</nuxt-link>
@@ -320,55 +322,66 @@
:options="[
{
id: 'mods',
action: '/mods',
action: '/discover/mods',
},
{
id: 'resourcepacks',
action: '/resourcepacks',
action: '/discover/resourcepacks',
},
{
id: 'datapacks',
action: '/datapacks',
action: '/discover/datapacks',
},
{
id: 'shaders',
action: '/shaders',
action: '/discover/shaders',
},
{
id: 'modpacks',
action: '/modpacks',
action: '/discover/modpacks',
},
{
id: 'plugins',
action: '/plugins',
action: '/discover/plugins',
},
{
id: 'servers',
action: '/discover/servers',
shown: flags.serverDiscovery,
},
]"
hoverable
>
<BoxIcon
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
v-if="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
"
aria-hidden="true"
/>
<BracesIcon
v-else-if="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
v-else-if="
route.name === 'discover-datapacks' || route.path.startsWith('/datapack/')
"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
v-else-if="route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
v-else-if="route.name === 'discover-shaders' || route.path.startsWith('/shader/')"
aria-hidden="true"
/>
<PlugIcon
v-else-if="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
v-else-if="route.name === 'discover-plugins' || route.path.startsWith('/plugin/')"
aria-hidden="true"
/>
<ServerIcon
v-else-if="route.name === 'discover-servers' || route.path.startsWith('/server/')"
aria-hidden="true"
/>
<CompassIcon v-else aria-hidden="true" />
@@ -402,13 +415,17 @@
<PackageOpenIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
</template>
<template #servers>
<ServerIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.server) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="
route.name?.startsWith('hosting') ||
(route.name?.startsWith('search-') && route.query.sid)
(route.name?.startsWith('discover-') && !!route.query.sid)
"
:highlighted-style="
route.name === 'hosting' ? 'main-nav-primary' : 'main-nav-secondary'
@@ -1328,27 +1345,27 @@ const navRoutes = computed(() => [
{
id: 'mods',
label: formatMessage(getProjectTypeMessage('mod', true)),
href: '/mods',
href: '/discover/mods',
},
{
label: formatMessage(getProjectTypeMessage('plugin', true)),
href: '/plugins',
href: '/discover/plugins',
},
{
label: formatMessage(getProjectTypeMessage('datapack', true)),
href: '/datapacks',
href: '/discover/datapacks',
},
{
label: formatMessage(getProjectTypeMessage('shader', true)),
href: '/shaders',
href: '/discover/shaders',
},
{
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
href: '/resourcepacks',
href: '/discover/resourcepacks',
},
{
label: formatMessage(getProjectTypeMessage('modpack', true)),
href: '/modpacks',
href: '/discover/modpacks',
},
])
@@ -1439,7 +1456,7 @@ const userMenuOptions = computed(() => {
})
const isDiscovering = computed(
() => route.name && route.name.startsWith('search-') && !route.query.sid,
() => route.name && route.name.startsWith('discover-') && !route.query.sid,
)
const isDiscoveringSubpage = computed(

View File

@@ -968,6 +968,9 @@
"dashboard.withdraw.error.tax-form.title": {
"message": "Please complete tax form"
},
"discover.title": {
"message": "Discover"
},
"error.collection.404.list_item.1": {
"message": "You may have mistyped the collection's URL."
},

View File

@@ -0,0 +1,13 @@
export default defineNuxtRouteMiddleware((to) => {
if (
to.path.startsWith('/mods') ||
to.path.startsWith('/modpacks') ||
to.path.startsWith('/plugins') ||
to.path.startsWith('/datapacks') ||
to.path.startsWith('/resourcepacks') ||
to.path.startsWith('/shaders')
) {
const target = '/discover' + to.fullPath
return navigateTo(target, { redirectCode: 301 })
}
})

View File

@@ -364,7 +364,7 @@
<span v-if="auth.user && auth.user.id === creator.id" class="preserve-lines text">
<IntlFormatted :message-id="messages.noProjectsAuthLabel">
<template #create-link="{ children }">
<a class="link" @click.prevent="$router.push('/mods')">
<a class="link" @click.prevent="$router.push('/discover/mods')">
<component :is="() => children" />
</a>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { commonProjectTypeCategoryMessages } from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
import NavTabs from '~/components/ui/NavTabs.vue'
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const route = useRoute()
const allowTabChanging = computed(() => !route.query.sid)
const selectableProjectTypes = [
{
label: formatMessage(commonProjectTypeCategoryMessages.mod),
href: `/discover/mods`,
type: 'mods',
},
{
label: formatMessage(commonProjectTypeCategoryMessages.resourcepack),
href: `/discover/resourcepacks`,
type: 'resourcepacks',
},
{
label: formatMessage(commonProjectTypeCategoryMessages.datapack),
href: `/discover/datapacks`,
type: 'datapacks',
},
{
label: formatMessage(commonProjectTypeCategoryMessages.shader),
href: `/discover/shaders`,
type: 'shaders',
},
{
label: formatMessage(commonProjectTypeCategoryMessages.modpack),
href: `/discover/modpacks`,
type: 'modpacks',
},
{
label: formatMessage(commonProjectTypeCategoryMessages.plugin),
href: `/discover/plugins`,
type: 'plugins',
},
{
label: formatMessage(commonProjectTypeCategoryMessages.server),
href: `/discover/servers`,
type: 'servers',
shown: flags.value.serverDiscovery,
},
]
</script>
<template>
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
<section class="normal-page__header mb-4 flex flex-col gap-4">
<div id="discover-header-prefix" class="empty:hidden"></div>
<NavTabs
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
:links="selectableProjectTypes"
class="hidden md:flex"
/>
</section>
<NuxtPage />
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { commonProjectTypeCategoryMessages } from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
const route = useRoute()
const { formatMessage } = useVIntl()
if (!route.params.type || typeof route.params.type !== 'string') {
throw createError({
statusCode: 404,
})
}
const messages = defineMessages({
discover: {
id: 'discover.title',
defaultMessage: 'Discover',
},
})
function isProjectTypeKey(value: string): value is keyof typeof commonProjectTypeCategoryMessages {
return value in commonProjectTypeCategoryMessages
}
const type = route.params.type.replaceAll(/^\/|s\/?$/g, '')
const titleMessage = isProjectTypeKey(type)
? commonProjectTypeCategoryMessages[type]
: messages.discover
</script>
<template>
<Head>
<Title>{{ formatMessage(titleMessage) }} - Modrinth</Title>
</Head>
<NuxtPage :type="type" />
</template>

View File

@@ -0,0 +1,896 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
DownloadIcon,
FilterIcon,
GameIcon,
GridIcon,
ImageIcon,
LeftArrowIcon,
ListIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
ButtonStyled,
Checkbox,
DropdownSelect,
injectNotificationManager,
NewProjectCard,
Pagination,
SearchFilterControl,
SearchSidebarFilter,
type SortType,
useSearch,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
import { computed, type Reactive } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
const { formatMessage } = useVIntl()
const filtersMenuOpen = ref(false)
const route = useNativeRoute()
const router = useNativeRouter()
const cosmetics = useCosmetics()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
const { handleError } = injectNotificationManager()
const props = defineProps<{
type: string
}>()
const projectType = ref()
function setProjectType() {
const projType = tags.value.projectTypes.find((x) => x.id === props.type)
if (projType) {
projectType.value = projType
}
}
setProjectType()
router.afterEach(() => {
setProjectType()
})
const projectTypes = computed(() => [projectType.value.id])
const resultsDisplayLocation = computed<DisplayLocation | undefined>(
() => projectType.value?.id as DisplayLocation,
)
const resultsDisplayMode = computed<DisplayMode>(() =>
resultsDisplayLocation.value
? cosmetics.value.searchDisplayMode[resultsDisplayLocation.value]
: 'list',
)
const server = ref<Reactive<ModrinthServer>>()
const serverHideInstalled = ref(false)
const eraseDataOnInstall = ref(false)
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
await updateServerContext()
watch(route, () => {
updateServerContext()
})
async function updateServerContext() {
const serverId = queryAsString(route.query.sid)
if (serverId && (!server.value || server.value.serverId !== serverId)) {
if (!auth.value.user) {
router.push('/auth/sign-in?redirect=' + encodeURIComponent(route.fullPath))
} else {
server.value = await useModrinthServers(serverId, ['general', 'content'])
}
}
if (server.value?.serverId !== serverId && routeNameAsString(route.name)?.startsWith('search')) {
server.value = undefined
}
if (route.query.shi && projectType.value.id !== 'modpack' && server.value) {
serverHideInstalled.value = route.query.shi === 'true'
}
}
const serverFilters = computed(() => {
const filters = []
if (server.value && projectType.value.id !== 'modpack') {
const gameVersion = server.value.general?.mc_version
if (gameVersion) {
filters.push({
type: 'game_version',
option: gameVersion,
})
}
const platform = server.value.general?.loader?.toLowerCase()
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
if (platform && modLoaders.includes(platform)) {
filters.push({
type: 'mod_loader',
option: platform,
})
}
const pluginLoaders = ['paper', 'purpur']
if (platform && pluginLoaders.includes(platform)) {
filters.push({
type: 'plugin_loader',
option: platform,
})
}
if (serverHideInstalled.value) {
const installedMods = server.value.content?.data
.filter((x: InstallableMod) => x.project_id)
.map((x: InstallableMod) => x.project_id)
.filter((id): id is string => id !== undefined)
installedMods
.map((x: string) => ({
type: 'project_id',
option: `project_id:${x}`,
negative: true,
}))
.forEach((x) => filters.push(x))
}
}
return filters
})
const maxResultsForView = ref<Record<DisplayMode, number[]>>({
list: [5, 10, 15, 20, 50, 100],
grid: [6, 12, 18, 24, 48, 96],
gallery: [6, 10, 16, 20, 50, 100],
})
const currentMaxResultsOptions = computed(
() => maxResultsForView.value[resultsDisplayMode.value] ?? [20],
)
const {
// Selections
query,
currentSortType,
currentFilters,
toggledGroups,
maxResults,
currentPage,
overriddenProvidedFilterTypes,
// Lists
filters,
sortTypes,
// Computed
requestParams,
// Functions
createPageParams,
} = useSearch(projectTypes, tags, serverFilters)
const messages = defineMessages({
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
},
modLoaderProvidedByServer: {
id: 'search.filter.locked.server-loader.title',
defaultMessage: 'Loader is provided by the server',
},
providedByServer: {
id: 'search.filter.locked.server',
defaultMessage: 'Provided by the server',
},
syncFilterButton: {
id: 'search.filter.locked.server.sync',
defaultMessage: 'Sync with server',
},
})
interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject {
installing?: boolean
installed?: boolean
}
async function serverInstall(project: InstallableSearchResult) {
if (!server.value) {
handleError(new Error('No server to install to.'))
return
}
project.installing = true
try {
const versions = (await useBaseFetch(
`project/${project.project_id}/version`,
{},
true,
)) as Labrinth.Versions.v2.Version[]
const version =
versions.find(
(x) =>
x.game_versions.includes(server.value!.general.mc_version) &&
x.loaders.includes(server.value!.general.loader.toLowerCase()),
) ?? versions[0]
if (projectType.value.id === 'modpack') {
await server.value.general.reinstall(
false,
project.project_id,
version.id,
undefined,
eraseDataOnInstall.value,
)
project.installed = true
navigateTo(`/hosting/manage/${server.value.serverId}/options/loader`)
} else if (projectType.value.id === 'mod') {
await server.value.content.install('mod', version.project_id, version.id)
await server.value.refresh(['content'])
project.installed = true
} else if (projectType.value.id === 'plugin') {
await server.value.content.install('plugin', version.project_id, version.id)
await server.value.refresh(['content'])
project.installed = true
}
} catch (e) {
console.error(e)
handleError(new Error(`Error installing content ${e}`))
}
project.installing = false
}
const noLoad = ref(false)
const {
data: rawResults,
refresh: refreshSearch,
pending: searchLoading,
} = useLazyFetch(
() => {
const config = useRuntimeConfig()
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
return `${base}search${requestParams.value}`
},
{
transform: (hits) => {
noLoad.value = false
return hits as Labrinth.Search.v2.SearchResults
},
},
)
const results = shallowRef(toRaw(rawResults))
const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
function scrollToTop(behavior: ScrollBehavior = 'smooth') {
window.scrollTo({ top: 0, behavior })
}
function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
currentPage.value = pageNumber
if (resetScroll) {
scrollToTop()
}
noLoad.value = true
if (query.value === null) {
return
}
refreshSearch()
if (import.meta.client) {
const persistentParams: Record<string, any> = {}
for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value
}
}
if (serverHideInstalled.value) {
persistentParams.shi = 'true'
} else {
delete persistentParams.shi
}
const params = {
...persistentParams,
...createPageParams(),
}
router.replace({ path: route.path, query: params })
}
}
watch([currentFilters], () => {
updateSearchResults(1, false)
})
function cycleSearchDisplayMode() {
if (!resultsDisplayLocation.value) {
// if no display location, abort
return
}
cosmetics.value.searchDisplayMode[resultsDisplayLocation.value] = cycleValue(
cosmetics.value.searchDisplayMode[resultsDisplayLocation.value],
tags.value.projectViewModes,
)
setClosestMaxResults()
}
function setClosestMaxResults() {
const maxResultsOptions = maxResultsForView.value[resultsDisplayMode.value] ?? [20]
const currentMax = maxResults.value
if (!maxResultsOptions.includes(currentMax)) {
maxResults.value = maxResultsOptions.reduce((prev: number, curr: number) => {
return Math.abs(curr - currentMax) <= Math.abs(prev - currentMax) ? curr : prev
})
}
}
const ogTitle = computed(
() => `Search ${projectType.value.display}s${query.value ? ' | ' + query.value : ''}`,
)
const description = computed(
() =>
`Search and browse thousands of Minecraft ${projectType.value.display}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${projectType.value.display}s.`,
)
useSeoMeta({
description,
ogTitle,
ogDescription: description,
})
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<Teleport v-if="server" to="#discover-header-prefix">
<div
class="mb-4 flex flex-wrap items-center justify-between gap-3 border-0 border-b border-solid border-divider pb-4"
>
<nuxt-link
:to="`/servers/manage/${server.serverId}/content`"
tabindex="-1"
class="flex flex-col gap-4 text-primary"
>
<span class="flex items-center gap-2">
<Avatar
:src="
server.general.is_medal
? 'https://cdn-raw.modrinth.com/medal_icon.webp'
: server.general.image
"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="bold font-extrabold text-contrast">
{{ server.general.name }}
</span>
<span class="flex items-center gap-2 font-semibold text-secondary">
<GameIcon class="h-5 w-5 text-secondary" />
{{ server.general.loader }} {{ server.general.mc_version }}
</span>
</span>
</span>
</nuxt-link>
<ButtonStyled>
<nuxt-link :to="`/hosting/manage/${server.serverId}/content`">
<LeftArrowIcon />
Back to server
</nuxt-link>
</ButtonStyled>
</div>
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">Install content to server</h1>
</Teleport>
<aside
:class="{
'normal-page__sidebar': true,
}"
aria-label="Filters"
>
<AdPlaceholder v-if="!auth.user && !server" />
<div v-if="filtersMenuOpen" class="fixed inset-0 z-40 bg-bg"></div>
<div
class="flex flex-col gap-3"
:class="{
'fixed inset-0 z-50 m-4 mb-0 overflow-auto rounded-t-3xl bg-bg-raised': filtersMenuOpen,
}"
>
<div
v-if="filtersMenuOpen"
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="
() => {
filtersMenuOpen = false
scrollToTop('instant')
}
"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div
v-if="server && projectType.id === 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised"
>
<div class="flex flex-row items-center gap-2 px-6 py-4 text-contrast">
<h3 class="m-0 text-lg">Options</h3>
</div>
<div class="flex flex-row items-center justify-between gap-2 px-6">
<label for="erase-data-on-install"> Erase all data on install </label>
<input
id="erase-data-on-install"
v-model="eraseDataOnInstall"
label="Erase all data on install"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
</div>
<div class="px-6 py-4 text-sm">
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the selected modpack.
</div>
</div>
<div
v-if="server && projectType.id !== 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised p-4"
>
<Checkbox
v-model="serverHideInstalled"
label="Hide installed content"
class="filter-checkbox"
@update:model-value="updateSearchResults()"
/>
</div>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="true"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</div>
</aside>
<section class="normal-page__content">
<div class="flex flex-col gap-3">
<div class="iconified-input w-full">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-12"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="`Search ${projectType.display}s...`"
@input="updateSearchResults()"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="[...sortTypes]"
:display-name="(option?: SortType) => option?.display"
@change="updateSearchResults()"
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="maxResults"
name="Max results"
:options="currentMaxResultsOptions"
:default-value="maxResults"
class="!w-auto flex-grow md:flex-grow-0"
@change="updateSearchResults()"
>
<span class="font-semibold text-primary">View: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<div class="lg:hidden">
<ButtonStyled>
<button @click="filtersMenuOpen = true">
<FilterIcon />
Filter results...
</button>
</ButtonStyled>
</div>
<ButtonStyled circular>
<button
:v-tooltip="capitalizeString(resultsDisplayMode + ' view')"
:aria-label="capitalizeString(resultsDisplayMode + ' view')"
@click="cycleSearchDisplayMode()"
>
<GridIcon v-if="resultsDisplayMode === 'grid'" />
<ImageIcon v-else-if="resultsDisplayMode === 'gallery'" />
<ListIcon v-else />
</button>
</ButtonStyled>
<Pagination
:page="currentPage"
:count="pageCount"
class="mx-auto sm:ml-auto sm:mr-0"
@switch-page="updateSearchResults"
/>
</div>
<SearchFilterControl
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="serverFilters"
:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-message="messages.providedByServer"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
</div>
<div v-else class="search-results-container">
<div
id="search-results"
class="project-list"
:class="'display-mode--' + resultsDisplayMode"
role="list"
aria-label="Search results"
>
<template v-for="result in results?.hits" :key="result.project_id">
<ProjectCard
v-if="flags.oldProjectCards"
:id="result.slug ? result.slug : result.project_id"
:display="resultsDisplayMode"
:featured-image="
result.featured_gallery ? result.featured_gallery : result.gallery[0]
"
:type="result.project_type"
:author="result.author"
:name="result.title"
:description="result.description"
:created-at="result.date_created"
:updated-at="result.date_modified"
:downloads="result.downloads.toString()"
:follows="result.follows.toString()"
:icon-url="result.icon_url"
:client-side="result.client_side"
:server-side="result.server_side"
:categories="result.display_categories"
:search="true"
:show-updated-date="!server && currentSortType.name !== 'newest'"
:show-created-date="!server"
:hide-loaders="['resourcepack', 'datapack'].includes(projectType.id)"
:color="result.color ?? undefined"
>
<template v-if="server">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
class="btn btn-outline btn-primary"
>
<CheckIcon />
Installed
</button>
<button
v-else-if="(result as InstallableSearchResult).installing"
disabled
class="btn btn-outline btn-primary"
>
Installing...
</button>
<button
v-else
class="btn btn-outline btn-primary"
@click="serverInstall(result as InstallableSearchResult)"
>
<DownloadIcon />
Install
</button>
</template>
</ProjectCard>
<NuxtLink
v-if="flags.newProjectCards"
:to="`/${projectType.id}/${result.slug ? result.slug : result.project_id}`"
>
<NewProjectCard :project="result" :categories="result.display_categories">
<template v-if="false" #actions></template>
</NewProjectCard>
</NuxtLink>
</template>
</div>
</div>
<div class="pagination-after">
<pagination
:page="currentPage"
:count="pageCount"
class="justify-end"
@switch-page="updateSearchResults"
/>
</div>
</div>
</section>
</template>
<style lang="scss" scoped>
.normal-page__content {
// Passthrough children as grid items on mobile
display: contents;
@media screen and (min-width: 1024px) {
display: block;
}
}
// Move the filters "sidebar" on mobile underneath the search card
.normal-page__sidebar {
grid-row: 3;
// Always show on desktop
@media screen and (min-width: 1024px) {
display: block;
}
}
.filters-card {
padding: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
padding: var(--spacing-card-lg);
}
}
.sidebar-menu {
display: none;
}
.sidebar-menu_open {
display: block;
}
.sidebar-menu-heading {
margin: 1.5rem 0 0.5rem 0;
}
// EthicalAds
.content-wrapper {
grid-row: 1;
}
.search-controls {
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
flex-wrap: wrap;
padding: var(--spacing-card-md);
grid-row: 2;
.search-filter-container {
display: flex;
width: 100%;
align-items: center;
.sidebar-menu-close-button {
max-height: none;
// match height of the search field
height: 40px;
transition: box-shadow 0.1s ease-in-out;
margin-right: var(--spacing-card-md);
&.open {
color: var(--color-button-text-active);
background-color: var(--color-brand-highlight);
box-shadow:
inset 0 0 0 transparent,
0 0 0 2px var(--color-brand);
}
}
.iconified-input {
flex: 1;
input {
width: 100%;
margin: 0;
}
}
}
.sort-controls {
width: 100%;
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
flex-wrap: wrap;
align-items: center;
.labeled-control {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
.labeled-control__label {
white-space: nowrap;
}
}
.square-button {
margin-top: auto;
// match height of search dropdowns
height: 40px;
width: 40px; // make it square!
}
}
}
.search-controls__sorting {
min-width: 14rem;
}
.labeled-control__label,
.labeled-control__control {
display: block;
}
.pagination-before {
grid-row: 4;
}
.search-results-container {
grid-row: 5;
}
.pagination-after {
grid-row: 6;
}
.no-results {
text-align: center;
display: flow-root;
}
.loading-logo {
margin: 2rem;
}
#search-results {
min-height: 20vh;
}
@media screen and (min-width: 750px) {
.search-controls {
flex-wrap: nowrap;
flex-direction: row;
}
.sort-controls {
min-width: fit-content;
max-width: fit-content;
flex-wrap: nowrap;
}
.labeled-control {
align-items: center;
display: flex;
flex-direction: column !important;
flex-wrap: wrap;
gap: 0.5rem;
max-width: fit-content;
}
.labeled-control__label {
flex-shrink: 0;
margin-bottom: 0 !important;
}
}
@media screen and (min-width: 860px) {
.labeled-control {
flex-wrap: nowrap !important;
flex-direction: row !important;
}
}
@media screen and (min-width: 1024px) {
.sidebar-menu {
display: block;
margin-top: 0;
}
.sidebar-menu-close-button {
display: none;
}
.labeled-control {
flex-wrap: wrap !important;
flex-direction: column !important;
}
}
@media screen and (min-width: 1100px) {
.labeled-control {
flex-wrap: nowrap !important;
flex-direction: row !important;
}
}
.search-background {
width: 100%;
height: 20rem;
background-image: url('https://minecraft.wiki/images/The_Garden_Awakens_Key_Art_No_Creaking.jpg?9968c');
background-size: cover;
background-position: center;
pointer-events: none;
mask-image: linear-gradient(to bottom, black, transparent);
opacity: 0.25;
}
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
throw createError({
fatal: true,
statusCode: 404,
})
</script>

View File

@@ -27,7 +27,7 @@
</h2>
<div class="button-group">
<ButtonStyled color="brand" size="large">
<nuxt-link to="/mods">
<nuxt-link to="/discover/mods">
<CompassIcon aria-hidden="true" />
{{ formatMessage(messages.discoverMods) }}
</nuxt-link>

View File

@@ -1,891 +0,0 @@
<template>
<div
class="new-page sidebar experimental-styles-within"
:class="{ 'alt-layout': !cosmetics.rightSearchLayout }"
>
<Head>
<Title>Search {{ projectType.display }}s - Modrinth</Title>
</Head>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<section class="normal-page__header mb-4 flex flex-col gap-4">
<template v-if="server">
<div
class="flex flex-wrap items-center justify-between gap-3 border-0 border-b border-solid border-divider pb-4"
>
<nuxt-link
:to="`/hosting/manage/${server.serverId}/content`"
tabindex="-1"
class="flex flex-col gap-4 text-primary"
>
<span class="flex items-center gap-2">
<Avatar
:src="
server.general.is_medal
? 'https://cdn-raw.modrinth.com/medal_icon.webp'
: server.general.image
"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="bold font-extrabold text-contrast">
{{ server.general.name }}
</span>
<span class="flex items-center gap-2 font-semibold text-secondary">
<GameIcon class="h-5 w-5 text-secondary" />
{{ server.general.loader }} {{ server.general.mc_version }}
</span>
</span>
</span>
</nuxt-link>
<ButtonStyled>
<nuxt-link :to="`/hosting/manage/${server.serverId}/content`">
<LeftArrowIcon />
Back to server
</nuxt-link>
</ButtonStyled>
</div>
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
Install content to server
</h1>
</template>
<NavTabs
v-if="!server && !flags.projectTypesPrimaryNav"
:links="selectableProjectTypes"
class="hidden md:flex"
/>
</section>
<aside
:class="{
'normal-page__sidebar': true,
}"
aria-label="Filters"
>
<AdPlaceholder v-if="!auth.user && !server" />
<div v-if="filtersMenuOpen" class="fixed inset-0 z-40 bg-bg"></div>
<div
class="flex flex-col gap-3"
:class="{
'fixed inset-0 z-50 m-4 mb-0 overflow-auto rounded-t-3xl bg-bg-raised': filtersMenuOpen,
}"
>
<div
v-if="filtersMenuOpen"
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="
() => {
filtersMenuOpen = false
scrollToTop('instant')
}
"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div
v-if="server && projectType.id === 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised"
>
<div class="flex flex-row items-center gap-2 px-6 py-4 text-contrast">
<h3 class="m-0 text-lg">Options</h3>
</div>
<div class="flex flex-row items-center justify-between gap-2 px-6">
<label for="erase-data-on-install"> Erase all data on install </label>
<input
id="erase-data-on-install"
v-model="eraseDataOnInstall"
label="Erase all data on install"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
</div>
<div class="px-6 py-4 text-sm">
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the selected modpack.
</div>
</div>
<div
v-if="server && projectType.id !== 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised p-4"
>
<Checkbox
v-model="serverHideInstalled"
label="Hide installed content"
class="filter-checkbox"
@update:model-value="updateSearchResults()"
/>
</div>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="true"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</div>
</aside>
<section class="normal-page__content">
<div class="flex flex-col gap-3">
<div class="iconified-input w-full">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-12"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="`Search ${projectType.display}s...`"
@input="updateSearchResults()"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="sortTypes"
:display-name="(option) => option?.display"
@change="updateSearchResults()"
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="maxResults"
name="Max results"
:options="currentMaxResultsOptions"
:default-value="maxResults"
:model-value="maxResults"
class="!w-auto flex-grow md:flex-grow-0"
@change="updateSearchResults()"
>
<span class="font-semibold text-primary">View: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<div class="lg:hidden">
<ButtonStyled>
<button @click="filtersMenuOpen = true">
<FilterIcon />
Filter results...
</button>
</ButtonStyled>
</div>
<ButtonStyled circular>
<button
v-tooltip="$capitalizeString(cosmetics.searchDisplayMode[projectType.id]) + ' view'"
:aria-label="$capitalizeString(cosmetics.searchDisplayMode[projectType.id]) + ' view'"
@click="cycleSearchDisplayMode()"
>
<GridIcon v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'" />
<ImageIcon v-else-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'" />
<ListIcon v-else />
</button>
</ButtonStyled>
<Pagination
:page="currentPage"
:count="pageCount"
class="mx-auto sm:ml-auto sm:mr-0"
@switch-page="updateSearchResults"
/>
</div>
<SearchFilterControl
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="serverFilters"
:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-message="messages.providedByServer"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
</div>
<div v-else class="search-results-container">
<div
id="search-results"
class="project-list"
:class="'display-mode--' + cosmetics.searchDisplayMode[projectType.id]"
role="list"
aria-label="Search results"
>
<template v-for="result in results?.hits" :key="result.project_id">
<ProjectCard
v-if="flags.oldProjectCards"
:id="result.slug ? result.slug : result.project_id"
:display="cosmetics.searchDisplayMode[projectType.id]"
:featured-image="
result.featured_gallery ? result.featured_gallery : result.gallery[0]
"
:type="result.project_type"
:author="result.author"
:name="result.title"
:description="result.description"
:created-at="result.date_created"
:updated-at="result.date_modified"
:downloads="result.downloads.toString()"
:follows="result.follows.toString()"
:icon-url="result.icon_url"
:client-side="result.client_side"
:server-side="result.server_side"
:categories="result.display_categories"
:search="true"
:show-updated-date="!server && currentSortType.name !== 'newest'"
:show-created-date="!server"
:hide-loaders="['resourcepack', 'datapack'].includes(projectType.id)"
:color="result.color"
>
<template v-if="server">
<button
v-if="
result.installed ||
(server?.content?.data &&
server.content.data.find((x) => x.project_id === result.project_id)) ||
server.general?.project?.id === result.project_id
"
disabled
class="btn btn-outline btn-primary"
>
<CheckIcon />
Installed
</button>
<button
v-else-if="result.installing"
disabled
class="btn btn-outline btn-primary"
>
Installing...
</button>
<button v-else class="btn btn-outline btn-primary" @click="serverInstall(result)">
<DownloadIcon />
Install
</button>
</template>
</ProjectCard>
<NuxtLink
v-if="flags.newProjectCards"
:to="`/${projectType.id}/${result.slug ? result.slug : result.project_id}`"
>
<NewProjectCard :project="result" :categories="result.display_categories">
<template v-if="false" #actions></template>
</NewProjectCard>
</NuxtLink>
</template>
</div>
</div>
<div class="pagination-after">
<pagination
:page="currentPage"
:count="pageCount"
class="justify-end"
@switch-page="updateSearchResults"
/>
</div>
</div>
</section>
</div>
</template>
<script setup>
import {
CheckIcon,
DownloadIcon,
FilterIcon,
GameIcon,
GridIcon,
ImageIcon,
LeftArrowIcon,
ListIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
ButtonStyled,
Checkbox,
commonProjectTypeCategoryMessages,
DropdownSelect,
NewProjectCard,
Pagination,
SearchFilterControl,
SearchSidebarFilter,
useSearch,
} from '@modrinth/ui'
import { computed } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
const { formatMessage } = useVIntl()
const filtersMenuOpen = ref(false)
const data = useNuxtApp()
const route = useNativeRoute()
const router = useNativeRouter()
const cosmetics = useCosmetics()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
const projectType = ref()
function setProjectType() {
const projType = tags.value.projectTypes.find(
(x) => x.id === route.path.replaceAll(/^\/|s\/?$/g, ''), // Removes prefix `/` and suffixes `s` and `s/`
)
if (projType) {
projectType.value = projType
}
}
setProjectType()
router.afterEach(() => {
setProjectType()
})
const projectTypes = computed(() => [projectType.value.id])
const server = ref()
const serverHideInstalled = ref(false)
const eraseDataOnInstall = ref(false)
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
await updateServerContext()
watch(route, () => {
updateServerContext()
})
async function updateServerContext() {
if (route.query.sid && (!server.value || server.value.serverId !== route.query.sid)) {
if (!auth.value.user) {
router.push('/auth/sign-in?redirect=' + encodeURIComponent(route.fullPath))
} else if (route.query.sid !== null) {
server.value = await useModrinthServers(route.query.sid, ['general', 'content'], {
waitForModules: true,
})
}
}
if (
server.value &&
server.value.serverId !== route.query.sid &&
route.name.startsWith('search')
) {
server.value = undefined
}
if (route.query.shi && projectType.value.id !== 'modpack' && server.value) {
serverHideInstalled.value = route.query.shi === 'true'
}
}
const serverFilters = computed(() => {
const filters = []
if (server.value && projectType.value.id !== 'modpack') {
const gameVersion = server.value.general?.mc_version
if (gameVersion) {
filters.push({
type: 'game_version',
option: gameVersion,
})
}
const platform = server.value.general?.loader?.toLowerCase()
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
if (platform && modLoaders.includes(platform)) {
filters.push({
type: 'mod_loader',
option: platform,
})
}
const pluginLoaders = ['paper', 'purpur']
if (platform && pluginLoaders.includes(platform)) {
filters.push({
type: 'plugin_loader',
option: platform,
})
}
if (serverHideInstalled.value) {
const installedMods = server.value.content?.data
.filter((x) => x.project_id)
.map((x) => x.project_id)
installedMods
?.map((x) => ({
type: 'project_id',
option: `project_id:${x}`,
negative: true,
}))
.forEach((x) => filters.push(x))
}
}
return filters
})
const maxResultsForView = ref({
list: [5, 10, 15, 20, 50, 100],
grid: [6, 12, 18, 24, 48, 96],
gallery: [6, 10, 16, 20, 50, 100],
})
const currentMaxResultsOptions = computed(
() => maxResultsForView.value[cosmetics.value.searchDisplayMode[projectType.value.id]],
)
const {
// Selections
query,
currentSortType,
currentFilters,
toggledGroups,
maxResults,
currentPage,
overriddenProvidedFilterTypes,
// Lists
filters,
sortTypes,
// Computed
requestParams,
// Functions
createPageParams,
} = useSearch(projectTypes, tags, serverFilters)
const messages = defineMessages({
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
},
modLoaderProvidedByServer: {
id: 'search.filter.locked.server-loader.title',
defaultMessage: 'Loader is provided by the server',
},
providedByServer: {
id: 'search.filter.locked.server',
defaultMessage: 'Provided by the server',
},
syncFilterButton: {
id: 'search.filter.locked.server.sync',
defaultMessage: 'Sync with server',
},
})
async function serverInstall(project) {
project.installing = true
try {
const versions = await useBaseFetch(`project/${project.project_id}/version`, {}, false, true)
const version =
versions.find(
(x) =>
x.game_versions.includes(server.value.general.mc_version) &&
x.loaders.includes(server.value.general.loader.toLowerCase()),
) ?? versions[0]
if (projectType.value.id === 'modpack') {
await server.value.general.reinstall(
false,
project.project_id,
version.id,
undefined,
eraseDataOnInstall.value,
)
project.installed = true
navigateTo(`/hosting/manage/${server.value.serverId}/options/loader`)
} else if (projectType.value.id === 'mod') {
await server.value.content.install('mod', version.project_id, version.id)
await server.value.refresh(['content'])
project.installed = true
} else if (projectType.value.id === 'plugin') {
await server.value.content.install('plugin', version.project_id, version.id)
await server.value.refresh(['content'])
project.installed = true
}
} catch (e) {
console.error(e)
}
project.installing = false
}
const noLoad = ref(false)
const {
data: rawResults,
refresh: refreshSearch,
pending: searchLoading,
} = useLazyFetch(
() => {
const config = useRuntimeConfig()
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
return `${base}search${requestParams.value}`
},
{
transform: (hits) => {
noLoad.value = false
return hits
},
},
)
const results = shallowRef(toRaw(rawResults))
const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
function scrollToTop(behavior = 'smooth') {
window.scrollTo({ top: 0, behavior })
}
function updateSearchResults(pageNumber) {
currentPage.value = pageNumber || 1
scrollToTop()
noLoad.value = true
if (query.value === null) {
return
}
refreshSearch()
if (import.meta.client) {
const persistentParams = {}
for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value
}
}
if (serverHideInstalled.value) {
persistentParams.shi = 'true'
} else {
delete persistentParams.shi
}
const params = {
...persistentParams,
...createPageParams(),
}
router.replace({ path: route.path, query: params })
}
}
watch([currentFilters], () => {
updateSearchResults(1)
})
function cycleSearchDisplayMode() {
cosmetics.value.searchDisplayMode[projectType.value.id] = data.$cycleValue(
cosmetics.value.searchDisplayMode[projectType.value.id],
tags.value.projectViewModes,
)
setClosestMaxResults()
}
function setClosestMaxResults() {
const view = cosmetics.value.searchDisplayMode[projectType.value.id]
const maxResultsOptions = maxResultsForView.value[view] ?? [20]
const currentMax = maxResults.value
if (!maxResultsOptions.includes(currentMax)) {
maxResults.value = maxResultsOptions.reduce(function (prev, curr) {
return Math.abs(curr - currentMax) <= Math.abs(prev - currentMax) ? curr : prev
})
}
}
const selectableProjectTypes = computed(() => {
return [
{ label: formatMessage(commonProjectTypeCategoryMessages.mod), href: `/mods` },
{
label: formatMessage(commonProjectTypeCategoryMessages.resourcepack),
href: `/resourcepacks`,
},
{ label: formatMessage(commonProjectTypeCategoryMessages.datapack), href: `/datapacks` },
{ label: formatMessage(commonProjectTypeCategoryMessages.shader), href: `/shaders` },
{ label: formatMessage(commonProjectTypeCategoryMessages.modpack), href: `/modpacks` },
{ label: formatMessage(commonProjectTypeCategoryMessages.plugin), href: `/plugins` },
]
})
const ogTitle = computed(
() => `Search ${projectType.value.display}s${query.value ? ' | ' + query.value : ''}`,
)
const description = computed(
() =>
`Search and browse thousands of Minecraft ${projectType.value.display}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${projectType.value.display}s.`,
)
useSeoMeta({
description,
ogTitle,
ogDescription: description,
})
</script>
<style lang="scss" scoped>
.normal-page__content {
// Passthrough children as grid items on mobile
display: contents;
@media screen and (min-width: 1024px) {
display: block;
}
}
// Move the filters "sidebar" on mobile underneath the search card
.normal-page__sidebar {
grid-row: 3;
// Always show on desktop
@media screen and (min-width: 1024px) {
display: block;
}
}
.filters-card {
padding: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
padding: var(--spacing-card-lg);
}
}
.sidebar-menu {
display: none;
}
.sidebar-menu_open {
display: block;
}
.sidebar-menu-heading {
margin: 1.5rem 0 0.5rem 0;
}
// EthicalAds
.content-wrapper {
grid-row: 1;
}
.search-controls {
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
flex-wrap: wrap;
padding: var(--spacing-card-md);
grid-row: 2;
.search-filter-container {
display: flex;
width: 100%;
align-items: center;
.sidebar-menu-close-button {
max-height: none;
// match height of the search field
height: 40px;
transition: box-shadow 0.1s ease-in-out;
margin-right: var(--spacing-card-md);
&.open {
color: var(--color-button-text-active);
background-color: var(--color-brand-highlight);
box-shadow:
inset 0 0 0 transparent,
0 0 0 2px var(--color-brand);
}
}
.iconified-input {
flex: 1;
input {
width: 100%;
margin: 0;
}
}
}
.sort-controls {
width: 100%;
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
flex-wrap: wrap;
align-items: center;
.labeled-control {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
.labeled-control__label {
white-space: nowrap;
}
}
.square-button {
margin-top: auto;
// match height of search dropdowns
height: 40px;
width: 40px; // make it square!
}
}
}
.search-controls__sorting {
min-width: 14rem;
}
.labeled-control__label,
.labeled-control__control {
display: block;
}
.pagination-before {
grid-row: 4;
}
.search-results-container {
grid-row: 5;
}
.pagination-after {
grid-row: 6;
}
.no-results {
text-align: center;
display: flow-root;
}
.loading-logo {
margin: 2rem;
}
#search-results {
min-height: 20vh;
}
@media screen and (min-width: 750px) {
.search-controls {
flex-wrap: nowrap;
flex-direction: row;
}
.sort-controls {
min-width: fit-content;
max-width: fit-content;
flex-wrap: nowrap;
}
.labeled-control {
align-items: center;
display: flex;
flex-direction: column !important;
flex-wrap: wrap;
gap: 0.5rem;
max-width: fit-content;
}
.labeled-control__label {
flex-shrink: 0;
margin-bottom: 0 !important;
}
}
@media screen and (min-width: 860px) {
.labeled-control {
flex-wrap: nowrap !important;
flex-direction: row !important;
}
}
@media screen and (min-width: 1024px) {
.sidebar-menu {
display: block;
margin-top: 0;
}
.sidebar-menu-close-button {
display: none;
}
.labeled-control {
flex-wrap: wrap !important;
flex-direction: column !important;
}
}
@media screen and (min-width: 1100px) {
.labeled-control {
flex-wrap: nowrap !important;
flex-direction: row !important;
}
}
.search-background {
width: 100%;
height: 20rem;
background-image: url('https://minecraft.wiki/images/The_Garden_Awakens_Key_Art_No_Creaking.jpg?9968c');
background-size: cover;
background-position: center;
pointer-events: none;
mask-image: linear-gradient(to bottom, black, transparent);
opacity: 0.25;
}
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -7,8 +7,12 @@
<Description>Search for mods on Modrinth, the open source modding platform.</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16" type="image/x-icon">https://modrinth.com/favicon.ico</Image>
<Url type="text/html" method="get" template="https://modrinth.com/mods?q={searchTerms}" />
<Url
type="text/html"
method="get"
template="https://modrinth.com/discover/mods?q={searchTerms}"
/>
<Developer>Rinth, Inc.</Developer>
<Attribution>Rinth, Inc.</Attribution>
<moz:SearchForm>https://modrinth.com/mods</moz:SearchForm>
<moz:SearchForm>https://modrinth.com/discover/mods</moz:SearchForm>
</OpenSearchDescription>

View File

@@ -0,0 +1,13 @@
import type { LocationQueryValue, RouteRecordNameGeneric } from 'vue-router'
export function queryAsStringOrEmpty(query: LocationQueryValue | LocationQueryValue[]): string {
return Array.isArray(query) ? (query[0] ?? '') : (query ?? '')
}
export function queryAsString(query: LocationQueryValue | LocationQueryValue[]): string | null {
return Array.isArray(query) ? (query[0] ?? null) : (query ?? null)
}
export function routeNameAsString(name: RouteRecordNameGeneric | undefined): string | undefined {
return name && typeof name === 'string' ? (name as string) : undefined
}

View File

@@ -361,6 +361,122 @@ export namespace Labrinth {
}
}
export namespace Versions {
export namespace v2 {
export type VersionType = 'release' | 'beta' | 'alpha'
export type VersionStatus =
| 'listed'
| 'archived'
| 'draft'
| 'unlisted'
| 'scheduled'
| 'unknown'
export type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
export type FileType = 'required-resource-pack' | 'optional-resource-pack' | 'unknown'
export type VersionFile = {
hashes: Record<string, string>
url: string
filename: string
primary: boolean
size: number
file_type?: FileType
}
export type Dependency = {
file_name?: string
dependency_type: DependencyType
} & (
| {
project_id: string
}
| {
version_id: string
project_id?: string
}
)
export type Version = {
id: string
project_id: string
author_id: string
featured: boolean
name: string
version_number: string
changelog: string
changelog_url?: string | null
date_published: string
downloads: number
version_type: VersionType
status: VersionStatus
requested_status?: VersionStatus | null
files: VersionFile[]
dependencies: Dependency[]
game_versions: string[]
loaders: string[]
}
}
export namespace v3 {
export type VersionType = 'release' | 'beta' | 'alpha'
export type VersionStatus =
| 'listed'
| 'archived'
| 'draft'
| 'unlisted'
| 'scheduled'
| 'unknown'
export type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
export type FileType = 'required-resource-pack' | 'optional-resource-pack' | 'unknown'
export type VersionFile = {
hashes: Record<string, string>
url: string
filename: string
primary: boolean
size: number
file_type?: FileType
}
export type Dependency = {
version_id?: string
project_id?: string
file_name?: string
dependency_type: DependencyType
}
export type Version = {
id: string
project_id: string
author_id: string
featured: boolean
name: string
version_number: string
project_types: string[]
games: string[]
changelog: string
date_published: string
downloads: number
version_type: VersionType
status: VersionStatus
requested_status?: VersionStatus | null
files: VersionFile[]
dependencies: Dependency[]
loaders: string[]
ordering?: number | null
game_versions?: string[]
mrpack_loaders?: string[]
environment?: string
}
}
}
export namespace Tags {
export namespace v2 {
export interface Category {

View File

@@ -56,6 +56,16 @@ const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots()
watch(
() => props.openByDefault,
(newValue) => {
if (newValue !== toggledOpen.value) {
toggledOpen.value = newValue
}
},
{ immediate: true },
)
function open() {
toggledOpen.value = true
emit('onOpen')

View File

@@ -55,7 +55,6 @@ onUnmounted(() => {
}
})
function updateFade(scrollTop, offsetHeight, scrollHeight) {
console.log(scrollTop, offsetHeight, scrollHeight)
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
scrollableAtTop.value = scrollTop <= 0
}

View File

@@ -560,6 +560,15 @@
"project-type.resourcepack.lowercase": {
"defaultMessage": "{count, plural, one {resource pack} other {resource packs}}"
},
"project-type.server.capital": {
"defaultMessage": "{count, plural, one {Server} other {Servers}}"
},
"project-type.server.category": {
"defaultMessage": "Servers"
},
"project-type.server.lowercase": {
"defaultMessage": "{count, plural, one {server} other {servers}}"
},
"project-type.shader.capital": {
"defaultMessage": "{count, plural, one {Shader} other {Shaders}}"
},

View File

@@ -433,6 +433,10 @@ export const commonProjectTypeCategoryMessages = defineMessages({
id: 'project-type.shader.category',
defaultMessage: 'Shaders',
},
server: {
id: 'project-type.server.category',
defaultMessage: 'Servers',
},
})
export const commonProjectTypeTitleMessages = defineMessages({
@@ -460,6 +464,10 @@ export const commonProjectTypeTitleMessages = defineMessages({
id: 'project-type.shader.capital',
defaultMessage: '{count, plural, one {Shader} other {Shaders}}',
},
server: {
id: 'project-type.server.capital',
defaultMessage: '{count, plural, one {Server} other {Servers}}',
},
})
export const commonProjectTypeSentenceMessages = defineMessages({
@@ -487,6 +495,10 @@ export const commonProjectTypeSentenceMessages = defineMessages({
id: 'project-type.shader.lowercase',
defaultMessage: '{count, plural, one {shader} other {shaders}}',
},
server: {
id: 'project-type.server.lowercase',
defaultMessage: '{count, plural, one {server} other {servers}}',
},
})
export const commonSettingsMessages = defineMessages({

View File

@@ -1,3 +1,4 @@
import type { Labrinth } from '@modrinth/api-client'
import { ClientIcon, ServerIcon } from '@modrinth/assets'
import { formatCategory, formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils'
import { defineMessage, useVIntl } from '@vintl/vintl'
@@ -67,25 +68,10 @@ const ALL_PROJECT_TYPES: ProjectType[] = [
'plugin',
]
export interface Platform {
name: string
icon: string
supported_project_types: ProjectType[]
default: boolean
formatted_name: string
}
export interface Category {
icon: string
name: string
project_type: ProjectType
header: string
}
export interface Tags {
gameVersions: GameVersion[]
loaders: Platform[]
categories: Category[]
gameVersions: Labrinth.Tags.v2.GameVersion[]
loaders: Labrinth.Tags.v2.Loader[]
categories: Labrinth.Tags.v2.Category[]
}
export interface SortType {

View File

@@ -287,7 +287,7 @@ export const formatVersions = (versionArray, gameVersions) => {
return (output.length === 0 ? versionArray : output).join(', ')
}
export function cycleValue(value, values) {
export function cycleValue<T extends string>(value: T, values: T[]): T {
const index = values.indexOf(value) + 1
return values[index % values.length]
}