You've already forked AstralRinth
forked from didirus/AstralRinth
Refactor search page, migrate to /discover/ (#4862)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user