527 lines
15 KiB
Vue
527 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
|
import type Instance from '@/components/ui/Instance.vue'
|
|
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
|
import NavTabs from '@/components/ui/NavTabs.vue'
|
|
import SearchCard from '@/components/ui/SearchCard.vue'
|
|
import { get_search_results } from '@/helpers/cache.js'
|
|
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
|
|
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
|
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
|
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
|
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
|
|
import {
|
|
Button,
|
|
Checkbox,
|
|
DropdownSelect,
|
|
injectNotificationManager,
|
|
LoadingIndicator,
|
|
Pagination,
|
|
SearchFilterControl,
|
|
SearchSidebarFilter,
|
|
useSearch,
|
|
} from '@modrinth/ui'
|
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
|
import type { Ref } from 'vue'
|
|
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
|
import type { LocationQuery } from 'vue-router'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
const { handleError } = injectNotificationManager()
|
|
const { formatMessage } = useVIntl()
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
|
|
const projectTypes = computed(() => {
|
|
return [route.params.projectType as ProjectType]
|
|
})
|
|
|
|
const [categories, loaders, availableGameVersions] = await Promise.all([
|
|
get_categories().catch(handleError).then(ref),
|
|
get_loaders().catch(handleError).then(ref),
|
|
get_game_versions().catch(handleError).then(ref),
|
|
])
|
|
|
|
const tags: Ref<Tags> = computed(() => ({
|
|
gameVersions: availableGameVersions.value as GameVersion[],
|
|
loaders: loaders.value as Platform[],
|
|
categories: categories.value as Category[],
|
|
}))
|
|
|
|
type Instance = {
|
|
game_version: string
|
|
loader: string
|
|
path: string
|
|
install_stage: string
|
|
icon_path?: string
|
|
name: string
|
|
}
|
|
|
|
type InstanceProject = {
|
|
metadata: {
|
|
project_id: string
|
|
}
|
|
}
|
|
|
|
const instance: Ref<Instance | null> = ref(null)
|
|
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
|
|
const instanceHideInstalled = ref(false)
|
|
const newlyInstalled = ref([])
|
|
|
|
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
|
|
|
|
await updateInstanceContext()
|
|
|
|
watch(route, () => {
|
|
updateInstanceContext()
|
|
})
|
|
|
|
async function updateInstanceContext() {
|
|
if (route.query.i) {
|
|
;[instance.value, instanceProjects.value] = await Promise.all([
|
|
getInstance(route.query.i).catch(handleError),
|
|
getInstanceProjects(route.query.i).catch(handleError),
|
|
])
|
|
newlyInstalled.value = []
|
|
}
|
|
|
|
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
|
|
instanceHideInstalled.value = route.query.ai === 'true'
|
|
}
|
|
|
|
if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
|
|
instance.value = null
|
|
instanceHideInstalled.value = false
|
|
}
|
|
}
|
|
|
|
const instanceFilters = computed(() => {
|
|
const filters = []
|
|
|
|
if (instance.value) {
|
|
const gameVersion = instance.value.game_version
|
|
if (gameVersion) {
|
|
filters.push({
|
|
type: 'game_version',
|
|
option: gameVersion,
|
|
})
|
|
}
|
|
|
|
const platform = instance.value.loader
|
|
|
|
const supportedModLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
|
|
|
|
if (platform && projectTypes.value.includes('mod') && supportedModLoaders.includes(platform)) {
|
|
filters.push({
|
|
type: 'mod_loader',
|
|
option: platform,
|
|
})
|
|
}
|
|
|
|
if (instanceHideInstalled.value && instanceProjects.value) {
|
|
const installedMods = Object.values(instanceProjects.value)
|
|
.filter((x) => x.metadata)
|
|
.map((x) => x.metadata.project_id)
|
|
|
|
installedMods.push(...newlyInstalled.value)
|
|
|
|
installedMods
|
|
?.map((x) => ({
|
|
type: 'project_id',
|
|
option: `project_id:${x}`,
|
|
negative: true,
|
|
}))
|
|
.forEach((x) => filters.push(x))
|
|
}
|
|
}
|
|
|
|
return filters
|
|
})
|
|
|
|
const {
|
|
// Selections
|
|
query,
|
|
currentSortType,
|
|
currentFilters,
|
|
toggledGroups,
|
|
maxResults,
|
|
currentPage,
|
|
overriddenProvidedFilterTypes,
|
|
|
|
// Lists
|
|
filters,
|
|
sortTypes,
|
|
|
|
// Computed
|
|
requestParams,
|
|
|
|
// Functions
|
|
createPageParams,
|
|
} = useSearch(projectTypes, tags, instanceFilters)
|
|
|
|
const offline = ref(!navigator.onLine)
|
|
window.addEventListener('offline', () => {
|
|
offline.value = true
|
|
})
|
|
window.addEventListener('online', () => {
|
|
offline.value = false
|
|
})
|
|
|
|
const breadcrumbs = useBreadcrumbs()
|
|
breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: route.query })
|
|
|
|
const loading = ref(true)
|
|
|
|
const projectType = ref(route.params.projectType)
|
|
|
|
watch(projectType, () => {
|
|
loading.value = true
|
|
})
|
|
|
|
type SearchResult = {
|
|
project_id: string
|
|
}
|
|
|
|
type SearchResults = {
|
|
total_hits: number
|
|
limit: number
|
|
hits: SearchResult[]
|
|
}
|
|
|
|
const results: Ref<SearchResults | null> = shallowRef(null)
|
|
const pageCount = computed(() =>
|
|
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
|
|
)
|
|
|
|
watch(requestParams, () => {
|
|
if (!route.params.projectType) return
|
|
refreshSearch()
|
|
})
|
|
|
|
async function refreshSearch() {
|
|
let rawResults = await get_search_results(requestParams.value)
|
|
if (!rawResults) {
|
|
rawResults = {
|
|
result: {
|
|
hits: [],
|
|
total_hits: 0,
|
|
limit: 1,
|
|
},
|
|
}
|
|
}
|
|
if (instance.value) {
|
|
for (const val of rawResults.result.hits) {
|
|
val.installed =
|
|
newlyInstalled.value.includes(val.project_id) ||
|
|
Object.values(instanceProjects.value).some(
|
|
(x) => x.metadata && x.metadata.project_id === val.project_id,
|
|
)
|
|
}
|
|
}
|
|
results.value = rawResults.result
|
|
currentPage.value = 1
|
|
|
|
const persistentParams: LocationQuery = {}
|
|
|
|
for (const [key, value] of Object.entries(route.query)) {
|
|
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
|
|
persistentParams[key] = value
|
|
}
|
|
}
|
|
|
|
if (instanceHideInstalled.value) {
|
|
persistentParams.ai = 'true'
|
|
} else {
|
|
delete persistentParams.ai
|
|
}
|
|
|
|
const params = {
|
|
...persistentParams,
|
|
...createPageParams(),
|
|
}
|
|
|
|
breadcrumbs.setContext({
|
|
name: 'Discover content',
|
|
link: `/browse/${projectType.value}`,
|
|
query: params,
|
|
})
|
|
await router.replace({ path: route.path, query: params })
|
|
loading.value = false
|
|
}
|
|
|
|
async function setPage(newPageNumber: number) {
|
|
currentPage.value = newPageNumber
|
|
|
|
await onSearchChangeToTop()
|
|
}
|
|
|
|
const searchWrapper: Ref<HTMLElement | null> = ref(null)
|
|
|
|
async function onSearchChangeToTop() {
|
|
await nextTick()
|
|
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
}
|
|
|
|
function clearSearch() {
|
|
query.value = ''
|
|
currentPage.value = 1
|
|
}
|
|
|
|
watch(
|
|
() => route.params.projectType,
|
|
async (newType) => {
|
|
// Check if the newType is not the same as the current value
|
|
if (!newType || newType === projectType.value) return
|
|
|
|
projectType.value = newType
|
|
|
|
currentSortType.value = { display: 'Relevance', name: 'relevance' }
|
|
query.value = ''
|
|
},
|
|
)
|
|
|
|
const selectableProjectTypes = computed(() => {
|
|
let dataPacks = false,
|
|
mods = false,
|
|
modpacks = false
|
|
|
|
if (instance.value) {
|
|
if (
|
|
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
|
|
availableGameVersions.value.findIndex((x) => x.version === '1.13')
|
|
) {
|
|
dataPacks = true
|
|
}
|
|
|
|
if (instance.value.loader !== 'vanilla') {
|
|
mods = true
|
|
}
|
|
} else {
|
|
dataPacks = true
|
|
mods = true
|
|
modpacks = true
|
|
}
|
|
|
|
const params: LocationQuery = {}
|
|
|
|
if (route.query.i) {
|
|
params.i = route.query.i
|
|
}
|
|
if (route.query.ai) {
|
|
params.ai = route.query.ai
|
|
}
|
|
|
|
const links = [
|
|
{ label: 'Modpacks', href: `/browse/modpack`, shown: modpacks },
|
|
{ label: 'Mods', href: `/browse/mod`, shown: mods },
|
|
{ label: 'Resource Packs', href: `/browse/resourcepack` },
|
|
{ label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks },
|
|
{ label: 'Shaders', href: `/browse/shader` },
|
|
]
|
|
|
|
if (params) {
|
|
return links.map((link) => {
|
|
return {
|
|
...link,
|
|
href: {
|
|
path: link.href,
|
|
query: params,
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
return links
|
|
})
|
|
|
|
const messages = defineMessages({
|
|
gameVersionProvidedByInstance: {
|
|
id: 'search.filter.locked.instance-game-version.title',
|
|
defaultMessage: 'Game version is provided by the instance',
|
|
},
|
|
modLoaderProvidedByInstance: {
|
|
id: 'search.filter.locked.instance-loader.title',
|
|
defaultMessage: 'Loader is provided by the instance',
|
|
},
|
|
providedByInstance: {
|
|
id: 'search.filter.locked.instance',
|
|
defaultMessage: 'Provided by the instance',
|
|
},
|
|
syncFilterButton: {
|
|
id: 'search.filter.locked.instance.sync',
|
|
defaultMessage: 'Sync with instance',
|
|
},
|
|
})
|
|
|
|
const options = ref(null)
|
|
const handleRightClick = (event, result) => {
|
|
options.value.showMenu(event, result, [
|
|
{
|
|
name: 'open_link',
|
|
},
|
|
{
|
|
name: 'copy_link',
|
|
},
|
|
])
|
|
}
|
|
const handleOptionsClick = (args) => {
|
|
switch (args.option) {
|
|
case 'open_link':
|
|
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
|
|
break
|
|
case 'copy_link':
|
|
navigator.clipboard.writeText(
|
|
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
|
|
)
|
|
break
|
|
}
|
|
}
|
|
|
|
await refreshSearch()
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport v-if="filters" to="#sidebar-teleport-target">
|
|
<div
|
|
v-if="instance"
|
|
class="border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
|
|
>
|
|
<Checkbox
|
|
v-model="instanceHideInstalled"
|
|
label="Hide installed content"
|
|
class="filter-checkbox"
|
|
@update:model-value="onSearchChangeToTop()"
|
|
@click.prevent.stop
|
|
/>
|
|
</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="instanceFilters"
|
|
:filter-type="filter"
|
|
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
|
|
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
|
|
content-class="mb-3"
|
|
inner-panel-class="ml-2 mr-3"
|
|
:open-by-default="
|
|
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
|
|
"
|
|
>
|
|
<template #header>
|
|
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
|
|
</template>
|
|
<template #locked-game_version>
|
|
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
|
|
</template>
|
|
<template #locked-mod_loader>
|
|
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
|
|
</template>
|
|
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
|
|
</SearchSidebarFilter>
|
|
</Teleport>
|
|
<div ref="searchWrapper" class="flex flex-col gap-3 p-6">
|
|
<template v-if="instance">
|
|
<InstanceIndicator :instance="instance" />
|
|
<h1 class="m-0 mb-1 text-xl">Install content to instance</h1>
|
|
</template>
|
|
<NavTabs :links="selectableProjectTypes" />
|
|
<div class="iconified-input">
|
|
<SearchIcon aria-hidden="true" class="text-lg" />
|
|
<input
|
|
v-model="query"
|
|
class="h-12 card-shadow"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
type="text"
|
|
:placeholder="`Search ${projectType}s...`"
|
|
/>
|
|
<Button v-if="query" class="r-btn" @click="() => clearSearch()">
|
|
<XIcon />
|
|
</Button>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<DropdownSelect
|
|
v-slot="{ selected }"
|
|
v-model="currentSortType"
|
|
class="max-w-[16rem]"
|
|
name="Sort by"
|
|
:options="sortTypes as any"
|
|
:display-name="(option: SortType | undefined) => option?.display"
|
|
>
|
|
<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="[5, 10, 15, 20, 50, 100]"
|
|
class="max-w-[9rem]"
|
|
>
|
|
<span class="font-semibold text-primary">View: </span>
|
|
<span class="font-semibold text-secondary">{{ selected }}</span>
|
|
</DropdownSelect>
|
|
<Pagination :page="currentPage" :count="pageCount" class="ml-auto" @switch-page="setPage" />
|
|
</div>
|
|
<SearchFilterControl
|
|
v-model:selected-filters="currentFilters"
|
|
:filters="filters.filter((f) => f.display !== 'none')"
|
|
:provided-filters="instanceFilters"
|
|
:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
|
:provided-message="messages.providedByInstance"
|
|
/>
|
|
<div class="search">
|
|
<section v-if="loading" class="offline">
|
|
<LoadingIndicator />
|
|
</section>
|
|
<section v-else-if="offline && results.total_hits === 0" class="offline">
|
|
You are currently offline. Connect to the internet to browse Modrinth!
|
|
</section>
|
|
<section v-else class="project-list display-mode--list instance-results" role="list">
|
|
<SearchCard
|
|
v-for="result in results.hits"
|
|
:key="result?.project_id"
|
|
:project="result"
|
|
:instance="instance"
|
|
:categories="[
|
|
...categories.filter(
|
|
(cat) =>
|
|
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
|
|
),
|
|
...loaders.filter(
|
|
(loader) =>
|
|
result?.display_categories.includes(loader.name) &&
|
|
loader.supported_project_types?.includes(projectType),
|
|
),
|
|
]"
|
|
:installed="result.installed || newlyInstalled.includes(result.project_id)"
|
|
@install="
|
|
(id) => {
|
|
newlyInstalled.push(id)
|
|
}
|
|
"
|
|
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
|
|
/>
|
|
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
|
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
|
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
|
</ContextMenu>
|
|
</section>
|
|
<div class="flex justify-end">
|
|
<pagination
|
|
:page="currentPage"
|
|
:count="pageCount"
|
|
class="pagination-after"
|
|
@switch-page="setPage"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|