You've already forked AstralRinth
forked from xxxOFFxxx/AstralRinth
Refactor search page, migrate to /discover/ (#4862)
This commit is contained in:
@@ -7,7 +7,7 @@ import { consola } from 'consola'
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import { globIterate } from 'glob'
|
import { globIterate } from 'glob'
|
||||||
import { defineNuxtConfig } from 'nuxt/config'
|
import { defineNuxtConfig } from 'nuxt/config'
|
||||||
import { basename, relative, resolve } from 'pathe'
|
import { basename, relative } from 'pathe'
|
||||||
import svgLoader from 'vite-svg-loader'
|
import svgLoader from 'vite-svg-loader'
|
||||||
|
|
||||||
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
|
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
|
||||||
@@ -176,23 +176,6 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
console.log('Tags generated!')
|
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) {
|
async 'vintl:extendOptions'(opts) {
|
||||||
opts.locales ??= []
|
opts.locales ??= []
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
|||||||
newProjectGeneralSettings: false,
|
newProjectGeneralSettings: false,
|
||||||
newProjectEnvironmentSettings: true,
|
newProjectEnvironmentSettings: true,
|
||||||
hideRussiaCensorshipBanner: false,
|
hideRussiaCensorshipBanner: false,
|
||||||
|
serverDiscovery: false,
|
||||||
// advancedRendering: true,
|
// advancedRendering: true,
|
||||||
// externalLinksNewTab: true,
|
// externalLinksNewTab: true,
|
||||||
// notUsingBlockers: false,
|
// notUsingBlockers: false,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { ISO3166, Labrinth } from '@modrinth/api-client'
|
import type { ISO3166, Labrinth } from '@modrinth/api-client'
|
||||||
|
import type { DisplayProjectType } from '@modrinth/utils'
|
||||||
|
|
||||||
import generatedState from '~/generated/state.json'
|
import generatedState from '~/generated/state.json'
|
||||||
|
import type { DisplayMode } from '~/plugins/cosmetics'
|
||||||
|
|
||||||
export interface ProjectType {
|
export interface ProjectType {
|
||||||
actual: string
|
actual: string
|
||||||
id: string
|
id: DisplayProjectType
|
||||||
display: string
|
display: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export interface GeneratedState extends Labrinth.State.GeneratedState {
|
|||||||
// Additional runtime-defined fields not from the API
|
// Additional runtime-defined fields not from the API
|
||||||
projectTypes: ProjectType[]
|
projectTypes: ProjectType[]
|
||||||
loaderData: LoaderData
|
loaderData: LoaderData
|
||||||
projectViewModes: string[]
|
projectViewModes: DisplayMode[]
|
||||||
approvedStatuses: string[]
|
approvedStatuses: string[]
|
||||||
rejectedStatuses: string[]
|
rejectedStatuses: string[]
|
||||||
staffRoles: string[]
|
staffRoles: string[]
|
||||||
|
|||||||
@@ -237,12 +237,12 @@
|
|||||||
<template v-if="flags.projectTypesPrimaryNav">
|
<template v-if="flags.projectTypesPrimaryNav">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
:highlighted="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
|
||||||
:highlighted-style="
|
: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" />
|
<BoxIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.mod) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.mod) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -250,61 +250,63 @@
|
|||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="
|
:highlighted="
|
||||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||||
"
|
"
|
||||||
:highlighted-style="
|
: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" />
|
<PaintbrushIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.resourcepack) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.resourcepack) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
|
:highlighted="
|
||||||
|
route.name === 'discover-datapacks' || route.path.startsWith('/datapack/')
|
||||||
|
"
|
||||||
:highlighted-style="
|
: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" />
|
<BracesIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
|
:highlighted="route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')"
|
||||||
:highlighted-style="
|
: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" />
|
<PackageOpenIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
|
:highlighted="route.name === 'discover-shaders' || route.path.startsWith('/shader/')"
|
||||||
:highlighted-style="
|
: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" />
|
<GlassesIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
|
:highlighted="route.name === 'discover-plugins' || route.path.startsWith('/plugin/')"
|
||||||
:highlighted-style="
|
: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" />
|
<PlugIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.plugin) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.plugin) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -320,55 +322,66 @@
|
|||||||
:options="[
|
:options="[
|
||||||
{
|
{
|
||||||
id: 'mods',
|
id: 'mods',
|
||||||
action: '/mods',
|
action: '/discover/mods',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'resourcepacks',
|
id: 'resourcepacks',
|
||||||
action: '/resourcepacks',
|
action: '/discover/resourcepacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'datapacks',
|
id: 'datapacks',
|
||||||
action: '/datapacks',
|
action: '/discover/datapacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'shaders',
|
id: 'shaders',
|
||||||
action: '/shaders',
|
action: '/discover/shaders',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'modpacks',
|
id: 'modpacks',
|
||||||
action: '/modpacks',
|
action: '/discover/modpacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'plugins',
|
id: 'plugins',
|
||||||
action: '/plugins',
|
action: '/discover/plugins',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'servers',
|
||||||
|
action: '/discover/servers',
|
||||||
|
shown: flags.serverDiscovery,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
hoverable
|
hoverable
|
||||||
>
|
>
|
||||||
<BoxIcon
|
<BoxIcon
|
||||||
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
v-if="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<PaintbrushIcon
|
<PaintbrushIcon
|
||||||
v-else-if="
|
v-else-if="
|
||||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||||
"
|
"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<BracesIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<PackageOpenIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<GlassesIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<PlugIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<CompassIcon v-else aria-hidden="true" />
|
<CompassIcon v-else aria-hidden="true" />
|
||||||
@@ -402,13 +415,17 @@
|
|||||||
<PackageOpenIcon aria-hidden="true" />
|
<PackageOpenIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #servers>
|
||||||
|
<ServerIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonProjectTypeCategoryMessages.server) }}
|
||||||
|
</template>
|
||||||
</TeleportOverflowMenu>
|
</TeleportOverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="
|
:highlighted="
|
||||||
route.name?.startsWith('hosting') ||
|
route.name?.startsWith('hosting') ||
|
||||||
(route.name?.startsWith('search-') && route.query.sid)
|
(route.name?.startsWith('discover-') && !!route.query.sid)
|
||||||
"
|
"
|
||||||
:highlighted-style="
|
:highlighted-style="
|
||||||
route.name === 'hosting' ? 'main-nav-primary' : 'main-nav-secondary'
|
route.name === 'hosting' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||||
@@ -1328,27 +1345,27 @@ const navRoutes = computed(() => [
|
|||||||
{
|
{
|
||||||
id: 'mods',
|
id: 'mods',
|
||||||
label: formatMessage(getProjectTypeMessage('mod', true)),
|
label: formatMessage(getProjectTypeMessage('mod', true)),
|
||||||
href: '/mods',
|
href: '/discover/mods',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('plugin', true)),
|
label: formatMessage(getProjectTypeMessage('plugin', true)),
|
||||||
href: '/plugins',
|
href: '/discover/plugins',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('datapack', true)),
|
label: formatMessage(getProjectTypeMessage('datapack', true)),
|
||||||
href: '/datapacks',
|
href: '/discover/datapacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('shader', true)),
|
label: formatMessage(getProjectTypeMessage('shader', true)),
|
||||||
href: '/shaders',
|
href: '/discover/shaders',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
|
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
|
||||||
href: '/resourcepacks',
|
href: '/discover/resourcepacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('modpack', true)),
|
label: formatMessage(getProjectTypeMessage('modpack', true)),
|
||||||
href: '/modpacks',
|
href: '/discover/modpacks',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1439,7 +1456,7 @@ const userMenuOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isDiscovering = 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(
|
const isDiscoveringSubpage = computed(
|
||||||
|
|||||||
@@ -968,6 +968,9 @@
|
|||||||
"dashboard.withdraw.error.tax-form.title": {
|
"dashboard.withdraw.error.tax-form.title": {
|
||||||
"message": "Please complete tax form"
|
"message": "Please complete tax form"
|
||||||
},
|
},
|
||||||
|
"discover.title": {
|
||||||
|
"message": "Discover"
|
||||||
|
},
|
||||||
"error.collection.404.list_item.1": {
|
"error.collection.404.list_item.1": {
|
||||||
"message": "You may have mistyped the collection's URL."
|
"message": "You may have mistyped the collection's URL."
|
||||||
},
|
},
|
||||||
|
|||||||
13
apps/frontend/src/middleware/search-redirect.global.ts
Normal file
13
apps/frontend/src/middleware/search-redirect.global.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
<span v-if="auth.user && auth.user.id === creator.id" class="preserve-lines text">
|
<span v-if="auth.user && auth.user.id === creator.id" class="preserve-lines text">
|
||||||
<IntlFormatted :message-id="messages.noProjectsAuthLabel">
|
<IntlFormatted :message-id="messages.noProjectsAuthLabel">
|
||||||
<template #create-link="{ children }">
|
<template #create-link="{ children }">
|
||||||
<a class="link" @click.prevent="$router.push('/mods')">
|
<a class="link" @click.prevent="$router.push('/discover/mods')">
|
||||||
<component :is="() => children" />
|
<component :is="() => children" />
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
65
apps/frontend/src/pages/discover.vue
Normal file
65
apps/frontend/src/pages/discover.vue
Normal 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>
|
||||||
35
apps/frontend/src/pages/discover/[type].vue
Normal file
35
apps/frontend/src/pages/discover/[type].vue
Normal 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>
|
||||||
896
apps/frontend/src/pages/discover/[type]/index.vue
Normal file
896
apps/frontend/src/pages/discover/[type]/index.vue
Normal 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>
|
||||||
6
apps/frontend/src/pages/discover/index.vue
Normal file
6
apps/frontend/src/pages/discover/index.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
throw createError({
|
||||||
|
fatal: true,
|
||||||
|
statusCode: 404,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<ButtonStyled color="brand" size="large">
|
<ButtonStyled color="brand" size="large">
|
||||||
<nuxt-link to="/mods">
|
<nuxt-link to="/discover/mods">
|
||||||
<CompassIcon aria-hidden="true" />
|
<CompassIcon aria-hidden="true" />
|
||||||
{{ formatMessage(messages.discoverMods) }}
|
{{ formatMessage(messages.discoverMods) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -7,8 +7,12 @@
|
|||||||
<Description>Search for mods on Modrinth, the open source modding platform.</Description>
|
<Description>Search for mods on Modrinth, the open source modding platform.</Description>
|
||||||
<InputEncoding>UTF-8</InputEncoding>
|
<InputEncoding>UTF-8</InputEncoding>
|
||||||
<Image width="16" height="16" type="image/x-icon">https://modrinth.com/favicon.ico</Image>
|
<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>
|
<Developer>Rinth, Inc.</Developer>
|
||||||
<Attribution>Rinth, Inc.</Attribution>
|
<Attribution>Rinth, Inc.</Attribution>
|
||||||
<moz:SearchForm>https://modrinth.com/mods</moz:SearchForm>
|
<moz:SearchForm>https://modrinth.com/discover/mods</moz:SearchForm>
|
||||||
</OpenSearchDescription>
|
</OpenSearchDescription>
|
||||||
|
|||||||
13
apps/frontend/src/utils/router.ts
Normal file
13
apps/frontend/src/utils/router.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 Tags {
|
||||||
export namespace v2 {
|
export namespace v2 {
|
||||||
export interface Category {
|
export interface Category {
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ const emit = defineEmits(['onOpen', 'onClose'])
|
|||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.openByDefault,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue !== toggledOpen.value) {
|
||||||
|
toggledOpen.value = newValue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
toggledOpen.value = true
|
toggledOpen.value = true
|
||||||
emit('onOpen')
|
emit('onOpen')
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
||||||
console.log(scrollTop, offsetHeight, scrollHeight)
|
|
||||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||||
scrollableAtTop.value = scrollTop <= 0
|
scrollableAtTop.value = scrollTop <= 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,6 +560,15 @@
|
|||||||
"project-type.resourcepack.lowercase": {
|
"project-type.resourcepack.lowercase": {
|
||||||
"defaultMessage": "{count, plural, one {resource pack} other {resource packs}}"
|
"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": {
|
"project-type.shader.capital": {
|
||||||
"defaultMessage": "{count, plural, one {Shader} other {Shaders}}"
|
"defaultMessage": "{count, plural, one {Shader} other {Shaders}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -433,6 +433,10 @@ export const commonProjectTypeCategoryMessages = defineMessages({
|
|||||||
id: 'project-type.shader.category',
|
id: 'project-type.shader.category',
|
||||||
defaultMessage: 'Shaders',
|
defaultMessage: 'Shaders',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
id: 'project-type.server.category',
|
||||||
|
defaultMessage: 'Servers',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const commonProjectTypeTitleMessages = defineMessages({
|
export const commonProjectTypeTitleMessages = defineMessages({
|
||||||
@@ -460,6 +464,10 @@ export const commonProjectTypeTitleMessages = defineMessages({
|
|||||||
id: 'project-type.shader.capital',
|
id: 'project-type.shader.capital',
|
||||||
defaultMessage: '{count, plural, one {Shader} other {Shaders}}',
|
defaultMessage: '{count, plural, one {Shader} other {Shaders}}',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
id: 'project-type.server.capital',
|
||||||
|
defaultMessage: '{count, plural, one {Server} other {Servers}}',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const commonProjectTypeSentenceMessages = defineMessages({
|
export const commonProjectTypeSentenceMessages = defineMessages({
|
||||||
@@ -487,6 +495,10 @@ export const commonProjectTypeSentenceMessages = defineMessages({
|
|||||||
id: 'project-type.shader.lowercase',
|
id: 'project-type.shader.lowercase',
|
||||||
defaultMessage: '{count, plural, one {shader} other {shaders}}',
|
defaultMessage: '{count, plural, one {shader} other {shaders}}',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
id: 'project-type.server.lowercase',
|
||||||
|
defaultMessage: '{count, plural, one {server} other {servers}}',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const commonSettingsMessages = defineMessages({
|
export const commonSettingsMessages = defineMessages({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { ClientIcon, ServerIcon } from '@modrinth/assets'
|
import { ClientIcon, ServerIcon } from '@modrinth/assets'
|
||||||
import { formatCategory, formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils'
|
import { formatCategory, formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils'
|
||||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||||
@@ -67,25 +68,10 @@ const ALL_PROJECT_TYPES: ProjectType[] = [
|
|||||||
'plugin',
|
'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 {
|
export interface Tags {
|
||||||
gameVersions: GameVersion[]
|
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||||
loaders: Platform[]
|
loaders: Labrinth.Tags.v2.Loader[]
|
||||||
categories: Category[]
|
categories: Labrinth.Tags.v2.Category[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SortType {
|
export interface SortType {
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export const formatVersions = (versionArray, gameVersions) => {
|
|||||||
return (output.length === 0 ? versionArray : output).join(', ')
|
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
|
const index = values.indexOf(value) + 1
|
||||||
return values[index % values.length]
|
return values[index % values.length]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user