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