Search UI improvements (#107)

* Base impl

* Make project type selectable

* Update Browse.vue

* address changes

* Quick create

* Run linter

* fix merge

* Addressed changes

* Installation improvements

* Run lint

* resourcepacks

* automatic installation of dependencies

* Fix bugs with search

* Addressed changes

* Run linter

* Fixed direct install not working

* Remove back to search

* Update Index.vue

* Addressed some changes

* Shader fix

* fix resetting

* Update Browse.vue

* Direct install from search

* More improvements

* Update SearchCard.vue

* Card V2

* Run linter

* add instance ignoring

* Update Browse.vue

* Finalize changes

* Update SearchCard.vue

* More adjustments

* Fix out of order rendering

* Run linter

* Fix issues

* Add unlisteners

---------

Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
Adrian O.V 2023-05-16 22:25:00 -04:00 committed by GitHub
parent 3fa0e99de2
commit c6e2133e15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 835 additions and 293 deletions

View File

@ -127,6 +127,14 @@ impl Children {
}
}
if !mc_exit_status.success() {
emit_process(
uuid,
current_pid,
ProcessPayloadType::Finished,
"Exited process",
)
.await?;
return Ok(mc_exit_status); // Err for a non-zero exit is handled in helper
}

View File

@ -75,24 +75,24 @@ const loading = useLoading()
<LibraryIcon />
<span v-if="!themeStore.collapsedNavigation">Library</span>
</RouterLink>
<Button
color="primary"
:class="{
'icon-only': themeStore.collapsedNavigation,
'collapsed-button': themeStore.collapsedNavigation,
'expanded-button': !themeStore.collapsedNavigation,
}"
@click="() => $refs.installationModal.show()"
>
<PlusIcon />
<span v-if="!themeStore.collapsedNavigation" class="no-wrap">New Instance</span>
</Button>
<Suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>
</div>
</div>
<div class="settings pages-list">
<Button
class="sleek-primary"
:class="{
'icon-only': themeStore.collapsedNavigation,
'collapsed-button': themeStore.collapsedNavigation,
'expanded-button': !themeStore.collapsedNavigation,
}"
@click="() => $refs.installationModal.show()"
>
<PlusIcon />
<span v-if="!themeStore.collapsedNavigation" class="no-wrap">New Instance</span>
</Button>
<RouterLink
to="/settings"
class="btn"
@ -136,6 +136,10 @@ const loading = useLoading()
</template>
<style lang="scss" scoped>
.sleek-primary {
background-color: var(--color-brand-highlight);
transition: all ease-in-out 0.1s;
}
.container {
--appbar-height: 3.25rem;
--sidebar-width: 5rem;

View File

@ -43,6 +43,8 @@ a {
.multiselect {
color: var(--color-base) !important;
outline: 2px solid transparent;
max-width: 15rem;
width: 100% !important;
.multiselect__input:focus-visible {
outline: none !important;
@ -167,3 +169,7 @@ a {
gap: 1rem;
color: var(--color-contrast);
}
input {
border: none !important;
}

View File

@ -0,0 +1,136 @@
<template>
<Modal ref="incompatibleModal" header="Incompatibility warning">
<div class="modal-body">
<p>
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
you're trying to install it on. Are you sure you want to continue? Dependencies will not be
installed.
</p>
<table>
<tr class="header">
<th>{{ instance?.metadata.name }}</th>
<th>{{ projectTitle }}</th>
</tr>
<tr class="content">
<td class="data">
{{ instance?.metadata.loader }} {{ instance?.metadata.game_version }}
</td>
<td>
<DropdownSelect
v-if="versions?.length > 1"
v-model="selectedVersion"
:options="versions"
placeholder="Select version"
:display-name="
(version) =>
`${version?.name} (${version?.loaders
.map((name) => formatCategory(name))
.join(', ')} - ${version?.game_versions.join(', ')})`
"
render-up
/>
<span v-else>
<span>
{{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
}}
- {{ selectedVersion?.game_versions.join(', ') }})
</span>
</span>
</td>
</tr>
</table>
<div class="button-group">
<Button @click="() => incompatibleModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()">
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
</Button>
</div>
</div>
</Modal>
</template>
<script setup>
import { Button, Modal, XIcon, DownloadIcon, DropdownSelect, formatCategory } from 'omorphia'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { defineExpose, ref } from 'vue'
const instance = ref(null)
const projectTitle = ref(null)
const versions = ref(null)
const selectedVersion = ref(null)
const incompatibleModal = ref(null)
const installing = ref(false)
let markInstalled = () => {}
defineExpose({
show: (instanceVal, projectTitleVal, selectedVersions, extMarkInstalled) => {
instance.value = instanceVal
projectTitle.value = projectTitleVal
versions.value = selectedVersions
selectedVersion.value = selectedVersions[0]
incompatibleModal.value.show()
markInstalled = extMarkInstalled
},
})
const install = async () => {
installing.value = true
await installMod(instance.value.path, selectedVersion.value.id)
installing.value = false
markInstalled()
incompatibleModal.value.hide()
}
</script>
<style lang="scss" scoped>
.data {
text-transform: capitalize;
}
table {
width: 100%;
border-radius: var(--radius-lg);
border-collapse: collapse;
box-shadow: 0 0 0 1px var(--color-button-bg);
}
th {
text-align: left;
padding: 1rem;
background-color: var(--color-bg);
overflow: hidden;
border-bottom: 1px solid var(--color-button-bg);
}
th:first-child {
border-top-left-radius: var(--radius-lg);
border-right: 1px solid var(--color-button-bg);
}
th:last-child {
border-top-right-radius: var(--radius-lg);
}
td {
padding: 1rem;
}
td:first-child {
border-right: 1px solid var(--color-button-bg);
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>

View File

@ -2,11 +2,15 @@
import { Button, Modal, XIcon, DownloadIcon } from 'omorphia'
import { install as pack_install } from '@/helpers/pack'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const version = ref('')
const title = ref('')
const icon = ref('')
const confirmModal = ref(null)
const installing = ref(false)
defineExpose({
show: (id, projectTitle, projectIcon) => {
@ -18,8 +22,11 @@ defineExpose({
})
async function install() {
confirmModal.value.hide()
installing.value = true
let id = await pack_install(version.value)
await pack_install(version.value, title.value, icon.value ? icon.value : null)
await router.push({ path: `/instance/${encodeURIComponent(id)}` })
confirmModal.value.hide()
}
</script>
@ -31,7 +38,9 @@ async function install() {
</p>
<div class="button-group">
<Button @click="() => $refs.confirmModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" @click="install()"><DownloadIcon /> Install</Button>
<Button color="primary" :disabled="installing" @click="install()"
><DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}</Button
>
</div>
</div>
</Modal>

View File

@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue'
import { onUnmounted, ref, useSlots } from 'vue'
import { useRouter } from 'vue-router'
import { ofetch } from 'ofetch'
import { Card, SaveIcon, XIcon, Avatar, AnimatedLogo } from 'omorphia'
@ -33,13 +33,14 @@ const playing = ref(false)
const uuid = ref(null)
const modLoading = ref(false)
const slots = useSlots()
const router = useRouter()
const seeInstance = async () => {
const instancePath = props.instance.metadata
? `/instance/${encodeURIComponent(props.instance.path)}`
: `/project/${encodeURIComponent(props.instance.project_id)}`
? `/instance/${encodeURIComponent(props.instance.path)}/`
: `/project/${encodeURIComponent(props.instance.project_id)}/`
await router.push(instancePath)
}
@ -118,31 +119,42 @@ const stop = async (e) => {
uuid.value = null
}
await process_listener((e) => {
const unlisten = await process_listener((e) => {
if (e.event === 'finished' && e.uuid === uuid.value) playing.value = false
})
onUnmounted(() => unlisten())
</script>
<template>
<div class="instance">
<Card v-if="props.small" class="instance-small-card button-base" @click="seeInstance">
<Avatar
:src="
!props.instance.metadata.icon ||
(props.instance.metadata.icon && props.instance.metadata.icon.startsWith('http'))
? props.instance.metadata.icon
: convertFileSrc(instance.metadata?.icon)
"
:alt="props.instance.metadata.name"
size="sm"
/>
<div class="instance-small-card__info">
<span class="title">{{ props.instance.metadata.name }}</span>
{{
props.instance.metadata.loader.charAt(0).toUpperCase() +
props.instance.metadata.loader.slice(1)
}}
{{ props.instance.metadata.game_version }}
<Card v-if="props.small" class="instance-small-card" :class="{ 'button-base': !slots.content }">
<div
class="instance-small-card__description"
:class="{ 'button-base': slots.content }"
@click="seeInstance"
>
<Avatar
:src="
!props.instance.metadata.icon ||
(props.instance.metadata.icon && props.instance.metadata.icon.startsWith('http'))
? props.instance.metadata.icon
: convertFileSrc(instance.metadata?.icon)
"
:alt="props.instance.metadata.name"
size="sm"
/>
<div class="instance-small-card__info">
<span class="title">{{ props.instance.metadata.name }}</span>
{{
props.instance.metadata.loader.charAt(0).toUpperCase() +
props.instance.metadata.loader.slice(1)
}}
{{ props.instance.metadata.game_version }}
</div>
</div>
<div v-if="slots.content" class="instance-small-card__content">
<slot name="content" />
</div>
</Card>
<Card
@ -180,7 +192,7 @@ await process_listener((e) => {
>
<PlayIcon />
</div>
<div v-else-if="modLoading === true && playing === false" class="cta loading">
<div v-else-if="modLoading === true && playing === false" class="cta loading-cta">
<AnimatedLogo class="loading" />
</div>
<div
@ -200,12 +212,27 @@ await process_listener((e) => {
<style lang="scss">
.instance-small-card {
background-color: var(--color-bg) !important;
padding: 1rem !important;
display: flex;
flex-direction: row;
flex-direction: column;
min-height: min-content !important;
gap: 1rem;
align-items: center;
gap: 0.5rem;
align-items: flex-start;
padding: 0;
.instance-small-card__description {
display: flex;
flex-direction: row;
justify-content: flex-start;
gap: 1rem;
flex-grow: 1;
padding: var(--gap-xl);
padding-bottom: 0;
width: 100%;
&:not(.button-base) {
padding-bottom: var(--gap-xl);
}
}
.instance-small-card__info {
display: flex;
@ -217,6 +244,15 @@ await process_listener((e) => {
font-weight: bolder;
}
}
.instance-small-card__content {
padding: var(--gap-xl);
padding-top: 0;
}
.cta {
display: none;
}
}
.instance {
@ -242,33 +278,6 @@ await process_listener((e) => {
}
}
.install {
background: var(--color-brand);
display: flex;
}
.stop {
background: var(--color-red);
display: flex;
}
.cta.loading {
background: hsl(220, 11%, 10%) !important;
display: flex;
justify-content: center;
align-items: center;
.loading {
width: 2.5rem !important;
height: 2.5rem !important;
}
svg {
width: 2.5rem !important;
height: 2.5rem !important;
}
}
.light-mode {
.instance-card-item {
background: hsl(0, 0%, 100%) !important;
@ -304,6 +313,33 @@ await process_listener((e) => {
filter: none !important; /* overrides button-base class */
box-shadow: var(--shadow-floating);
}
&.install {
background: var(--color-brand);
display: flex;
}
&.stop {
background: var(--color-red);
display: flex;
}
&.loading-cta {
background: hsl(220, 11%, 10%) !important;
display: flex;
justify-content: center;
align-items: center;
.loading {
width: 2.5rem !important;
height: 2.5rem !important;
svg {
width: 2.5rem !important;
height: 2.5rem !important;
}
}
}
}
.instance-card-item {

View File

@ -190,6 +190,7 @@ const upload_icon = async () => {
],
})
if (!icon.value) return
display_icon.value = tauri.convertFileSrc(icon.value)
}

View File

@ -11,7 +11,7 @@ import {
RightArrowIcon,
CheckIcon,
} from 'omorphia'
import { computed, ref, shallowRef } from 'vue'
import { computed, ref } from 'vue'
import { add_project_from_version as installMod, check_installed, list } from '@/helpers/profile'
import { tauri } from '@tauri-apps/api'
import { open } from '@tauri-apps/api/dialog'
@ -34,9 +34,9 @@ const gameVersion = ref(null)
const creatingInstance = ref(false)
defineExpose({
show: async (projectId, selectedVersion) => {
show: async (projectId, selectedVersions) => {
project.value = projectId
versions.value = selectedVersion
versions.value = selectedVersions
installModal.value.show()
searchFilter.value = ''
@ -44,7 +44,7 @@ defineExpose({
},
})
const profiles = shallowRef(await getData())
const profiles = ref([])
async function install(instance) {
instance.installing = true
@ -109,6 +109,7 @@ const upload_icon = async () => {
],
})
if (!icon.value) return
display_icon.value = tauri.convertFileSrc(icon.value)
}
@ -147,7 +148,7 @@ const check_valid = computed(() => {
<Modal ref="installModal" header="Install mod to instance">
<div class="modal-body">
<input v-model="searchFilter" type="text" class="search" placeholder="Search for a profile" />
<div class="profiles">
<div class="profiles" :class="{ 'hide-creation': !showCreation }">
<div v-for="profile in profiles" :key="profile.metadata.name" class="option">
<Button
color="raised"
@ -265,6 +266,10 @@ const check_valid = computed(() => {
.profiles {
max-height: 12rem;
overflow-y: auto;
&.hide-creation {
max-height: 21rem;
}
}
.option {
@ -277,6 +282,7 @@ const check_valid = computed(() => {
justify-content: space-between;
align-items: center;
padding: 0 0.5rem;
gap: 0.5rem;
img {
margin-right: 0.5rem;
@ -289,7 +295,9 @@ const check_valid = computed(() => {
}
.profile-button {
align-content: start;
padding: 0.5rem;
text-align: left;
}
}

View File

@ -67,8 +67,7 @@ const showCard = ref(false)
const currentProcesses = ref(await getRunningProfiles())
await process_listener(async (event) => {
console.log(event)
await process_listener(async () => {
await refresh()
})

View File

@ -0,0 +1,282 @@
<template>
<Card class="card button-base" @click="$router.push(`/project/${project.project_id}/`)">
<div class="icon">
<Avatar :src="project.icon_url" size="md" class="search-icon" />
</div>
<div class="content-wrapper">
<div class="title joined-text">
<h2>{{ project.title }}</h2>
<span>by {{ project.author }}</span>
</div>
<div class="description">
{{ project.description }}
</div>
<div class="tags">
<Categories :categories="categories" :type="project.project_type">
<EnvironmentIndicator
:type-only="project.moderation"
:client-side="project.client_side"
:server-side="project.server_side"
:type="project.project_type"
:search="true"
/>
</Categories>
</div>
</div>
<div class="stats button-group">
<div v-if="featured" class="badge">
<StarIcon />
Featured
</div>
<div class="badge">
<DownloadIcon />
{{ formatNumber(project.downloads) }}
</div>
<div class="badge">
<HeartIcon />
{{ formatNumber(project.follows) }}
</div>
<div class="badge">
<CalendarIcon />
{{ formatCategory(dayjs(project.date_modified).fromNow()) }}
</div>
</div>
<div class="install">
<Button
:to="`/browse/${project.slug}`"
color="primary"
:disabled="installed || installing"
@click.stop="install()"
>
<DownloadIcon v-if="!installed" />
<CheckIcon v-else />
{{ installing ? 'Installing' : installed ? 'Installed' : 'Install' }}
</Button>
</div>
</Card>
</template>
<script setup>
import {
Avatar,
Card,
Categories,
EnvironmentIndicator,
Button,
DownloadIcon,
formatNumber,
formatCategory,
HeartIcon,
CalendarIcon,
CheckIcon,
StarIcon,
} from 'omorphia'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref } from 'vue'
import { add_project_from_version as installMod, list } from '@/helpers/profile.js'
import { install as packInstall } from '@/helpers/pack.js'
import { installVersionDependencies } from '@/helpers/utils.js'
import { ofetch } from 'ofetch'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({
backgroundImage: {
type: String,
default: null,
},
project: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
},
confirmModal: {
type: Object,
default: null,
},
modInstallModal: {
type: Object,
default: null,
},
incompatibilityWarningModal: {
type: Object,
default: null,
},
featured: {
type: Boolean,
default: false,
},
})
const installing = ref(false)
const installed = ref(
props.instance
? Object.values(props.instance.projects).some(
(p) => p.metadata?.project?.id === props.project.project_id
)
: false
)
async function install() {
installing.value = true
const versions = await ofetch(
`https://api.modrinth.com/v2/project/${props.project.project_id}/version`
)
let queuedVersionData
if (!props.instance) {
queuedVersionData = versions[0]
} else {
queuedVersionData = versions.find(
(v) =>
v.game_versions.includes(props.instance.metadata.game_version) &&
v.loaders.includes(props.instance.metadata.loader)
)
}
if (props.project.project_type === 'modpack') {
const packs = Object.values(await list())
if (
packs.length === 0 ||
!packs
.map((value) => value.metadata)
.find((pack) => pack.linked_data?.project_id === props.project.project_id)
) {
let id = await packInstall(queuedVersionData.id, props.project.title, props.project.icon_url)
await router.push({ path: `/instance/${encodeURIComponent(id)}` })
} else {
props.confirmModal.show(queuedVersionData.id)
}
} else {
if (props.instance) {
if (!queuedVersionData) {
props.incompatibilityWarningModal.show(
props.instance,
props.project.title,
versions,
() => (installed.value = true)
)
installing.value = false
return
} else {
await installMod(props.instance.path, queuedVersionData.id)
installVersionDependencies(props.instance, queuedVersionData)
}
} else {
props.modInstallModal.show(props.project.project_id, versions)
installing.value = false
return
}
if (props.instance) installed.value = true
}
installing.value = false
}
</script>
<style scoped lang="scss">
.icon {
grid-column: 1;
grid-row: 1;
align-self: center;
height: 6rem;
}
.content-wrapper {
display: flex;
justify-content: space-between;
grid-column: 2 / 4;
flex-direction: column;
grid-row: 1;
gap: 0.5rem;
.description {
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
.stats {
grid-column: 1 / 3;
grid-row: 2;
justify-self: stretch;
align-self: start;
}
.install {
grid-column: 3 / 4;
grid-row: 2;
justify-self: end;
align-self: start;
}
.card {
margin-bottom: 0;
display: grid;
grid-template-columns: 6rem auto 7rem;
gap: 0.75rem;
padding: 1rem;
&:active:not(&:disabled) {
scale: 0.98 !important;
}
}
.joined-text {
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
column-gap: 0.5rem;
align-items: baseline;
overflow: hidden;
text-overflow: ellipsis;
h2 {
margin-bottom: 0 !important;
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
.badge {
display: flex;
border-radius: var(--radius-md);
white-space: nowrap;
font-weight: 500;
align-items: center;
background-color: var(--color-bg);
padding-block: var(--gap-sm);
padding-inline: var(--gap-lg);
svg {
width: 1.1rem;
height: 1.1rem;
margin-right: 0.5rem;
}
&.featured {
background-color: var(--color-brand-highlight);
color: var(--color-contrast);
}
}
.button-group {
display: inline-flex;
flex-direction: row;
gap: 0.5rem;
align-items: flex-start;
flex-wrap: wrap;
justify-content: flex-start;
}
</style>

View File

@ -3,7 +3,6 @@ import { computed, onMounted, ref, watch } from 'vue'
import { ofetch } from 'ofetch'
import {
Pagination,
ProjectCard,
Checkbox,
Button,
ClearIcon,
@ -15,21 +14,37 @@ import {
ServerIcon,
NavRow,
formatCategoryHeader,
formatCategory,
} from 'omorphia'
import Multiselect from 'vue-multiselect'
import { useSearch } from '@/store/state'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { get_categories, get_loaders, get_game_versions } from '@/helpers/tags'
import { useRoute } from 'vue-router'
import Instance from '@/components/ui/Instance.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import InstallConfirmModal from '@/components/ui/InstallConfirmModal.vue'
import InstanceInstallModal from '@/components/ui/InstanceInstallModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import Instance from '@/components/ui/Instance.vue'
import IncompatibilityWarningModal from '@/components/ui/IncompatibilityWarningModal.vue'
const route = useRoute()
const searchStore = useSearch()
searchStore.projectType = route.params.projectType
const showVersions = ref(true)
const showLoaders = ref(true)
const showVersions = computed(
() => searchStore.instanceContext === null || searchStore.ignoreInstance
)
const showLoaders = computed(
() =>
searchStore.projectType !== 'datapack' &&
searchStore.projectType !== 'resourcepack' &&
searchStore.projectType !== 'shader' &&
(searchStore.instanceContext === null || searchStore.ignoreInstance)
)
const confirmModal = ref(null)
const modInstallModal = ref(null)
const incompatibilityWarningModal = ref(null)
const breadcrumbs = useBreadcrumbs()
@ -40,6 +55,12 @@ const categories = ref([])
const loaders = ref([])
const availableGameVersions = ref([])
breadcrumbs.setContext({ name: 'Browse', link: route.path })
if (searchStore.projectType === 'modpack') {
searchStore.instanceContext = null
}
onMounted(async () => {
;[categories.value, loaders.value, availableGameVersions.value] = await Promise.all([
get_categories(),
@ -72,12 +93,6 @@ const sortedCategories = computed(() => {
const getSearchResults = async () => {
const queryString = searchStore.getQueryString()
if (searchStore.instanceContext) {
showVersions.value = false
showLoaders.value = !(
searchStore.projectType === 'mod' || searchStore.projectType === 'resourcepack'
)
}
const response = await ofetch(`https://api.modrinth.com/v2/search${queryString}`)
searchStore.setSearchResults(response)
}
@ -119,131 +134,141 @@ const switchPage = async (page) => {
watch(
() => route.params.projectType,
async (projectType) => {
searchStore.projectType = projectType ?? 'modpack'
breadcrumbs.setContext({ name: 'Browse', link: route.path })
if (!projectType) return
searchStore.projectType = projectType
breadcrumbs.setContext({ name: 'Browse', link: `/browse/${searchStore.projectType}` })
await handleReset()
await switchPage(1)
}
)
const handleInstanceSwitch = async (value) => {
searchStore.ignoreInstance = value
await switchPage(1)
}
</script>
<template>
<div class="search-container">
<aside class="filter-panel">
<Instance v-if="searchStore.instanceContext" :instance="searchStore.instanceContext" small />
<Button
role="button"
:disabled="
!(
searchStore.facets.length > 0 ||
searchStore.orFacets.length > 0 ||
searchStore.environments.server === true ||
searchStore.environments.client === true ||
searchStore.openSource === true ||
searchStore.activeVersions.length > 0
)
"
@click="handleReset"
><ClearIcon />Clear Filters</Button
>
<div
v-for="categoryList in Array.from(sortedCategories)"
:key="categoryList[0]"
class="categories"
>
<h2>{{ formatCategoryHeader(categoryList[0]) }}</h2>
<div v-for="category in categoryList[1]" :key="category.name">
<SearchFilter
:active-filters="searchStore.facets"
:icon="category.icon"
:display-name="category.name"
:facet-name="`categories:${encodeURIComponent(category.name)}`"
<Instance v-if="searchStore.instanceContext" :instance="searchStore.instanceContext" small>
<template #content>
<Checkbox
:model-value="searchStore.ignoreInstance"
:checked="searchStore.ignoreInstance"
label="Unfilter loader & version"
class="filter-checkbox"
@toggle="toggleFacet"
@update:model-value="(value) => handleInstanceSwitch(value)"
/>
</div>
</div>
<div
v-if="
showLoaders &&
searchStore.projectType !== 'datapack' &&
searchStore.projectType !== 'resourcepack'
"
class="loaders"
>
<h2>Loaders</h2>
<div
v-for="loader in loaders.filter(
(l) =>
(searchStore.projectType !== 'mod' &&
l.supported_project_types?.includes(searchStore.projectType)) ||
(searchStore.projectType === 'mod' && ['fabric', 'forge', 'quilt'].includes(l.name))
)"
:key="loader"
>
<SearchFilter
:active-filters="searchStore.orFacets"
:icon="loader.icon"
:display-name="loader.name"
:facet-name="`categories:${encodeURIComponent(loader.name)}`"
class="filter-checkbox"
@toggle="toggleOrFacet"
/>
</div>
</div>
<div v-if="searchStore.projectType !== 'datapack'" class="environment">
<h2>Environments</h2>
<SearchFilter
v-model="searchStore.environments.client"
display-name="Client"
facet-name="client"
class="filter-checkbox"
@click="getSearchResults"
>
<ClientIcon aria-hidden="true" />
</SearchFilter>
<SearchFilter
v-model="searchStore.environments.server"
display-name="Server"
facet-name="server"
class="filter-checkbox"
@click="getSearchResults"
>
<ServerIcon aria-hidden="true" />
</SearchFilter>
</div>
<div v-if="showVersions" class="versions">
<h2>Minecraft versions</h2>
<Checkbox v-model="showSnapshots" class="filter-checkbox">Show snapshots</Checkbox>
<multiselect
v-model="searchStore.activeVersions"
:options="
showSnapshots
? availableGameVersions.map((x) => x.version)
: availableGameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
</template>
</Instance>
<Card class="search-panel-card">
<Button
role="button"
:disabled="
!(
searchStore.facets.length > 0 ||
searchStore.orFacets.length > 0 ||
searchStore.environments.server === true ||
searchStore.environments.client === true ||
searchStore.openSource === true ||
searchStore.activeVersions.length > 0
)
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-search-on-select="false"
:show-labels="false"
placeholder="Choose versions..."
@update:model-value="getSearchResults"
/>
</div>
<div class="open-source">
<h2>Open source</h2>
<Checkbox
v-model="searchStore.openSource"
class="filter-checkbox"
@click="getSearchResults"
@click="handleReset"
><ClearIcon />Clear Filters</Button
>
Open source
</Checkbox>
</div>
<div v-if="showLoaders" class="loaders">
<h2>Loaders</h2>
<div
v-for="loader in loaders.filter(
(l) =>
(searchStore.projectType !== 'mod' &&
l.supported_project_types?.includes(searchStore.projectType)) ||
(searchStore.projectType === 'mod' && ['fabric', 'forge', 'quilt'].includes(l.name))
)"
:key="loader"
>
<SearchFilter
:active-filters="searchStore.orFacets"
:icon="loader.icon"
:display-name="formatCategory(loader.name)"
:facet-name="`categories:${encodeURIComponent(loader.name)}`"
class="filter-checkbox"
@toggle="toggleOrFacet"
/>
</div>
</div>
<div
v-for="categoryList in Array.from(sortedCategories)"
:key="categoryList[0]"
class="categories"
>
<h2>{{ formatCategoryHeader(categoryList[0]) }}</h2>
<div v-for="category in categoryList[1]" :key="category.name">
<SearchFilter
:active-filters="searchStore.facets"
:icon="category.icon"
:display-name="formatCategory(category.name)"
:facet-name="`categories:${encodeURIComponent(category.name)}`"
class="filter-checkbox"
@toggle="toggleFacet"
/>
</div>
</div>
<div v-if="searchStore.projectType !== 'datapack'" class="environment">
<h2>Environments</h2>
<SearchFilter
v-model="searchStore.environments.client"
display-name="Client"
facet-name="client"
class="filter-checkbox"
@click="getSearchResults"
>
<ClientIcon aria-hidden="true" />
</SearchFilter>
<SearchFilter
v-model="searchStore.environments.server"
display-name="Server"
facet-name="server"
class="filter-checkbox"
@click="getSearchResults"
>
<ServerIcon aria-hidden="true" />
</SearchFilter>
</div>
<div v-if="showVersions" class="versions">
<h2>Minecraft versions</h2>
<Checkbox v-model="showSnapshots" class="filter-checkbox" label="Include snapshots" />
<multiselect
v-model="searchStore.activeVersions"
:options="
showSnapshots
? availableGameVersions.map((x) => x.version)
: availableGameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-search-on-select="false"
:show-labels="false"
placeholder="Choose versions..."
@update:model-value="getSearchResults"
/>
</div>
<div class="open-source">
<h2>Open source</h2>
<Checkbox
v-model="searchStore.openSource"
class="filter-checkbox"
label="Open source"
@click="getSearchResults"
/>
</div>
</Card>
</aside>
<div class="search">
<Card class="project-type-container">
@ -267,16 +292,16 @@ watch(
/>
</Card>
<Card class="search-panel-container">
<div class="search-panel">
<div class="iconified-input">
<SearchIcon aria-hidden="true" />
<input
v-model="searchStore.searchInput"
type="text"
:placeholder="`Search ${searchStore.projectType}s...`"
@input="getSearchResults"
/>
</div>
<div class="iconified-input">
<SearchIcon aria-hidden="true" />
<input
v-model="searchStore.searchInput"
type="text"
:placeholder="`Search ${searchStore.projectType}s...`"
@input="getSearchResults"
/>
</div>
<div class="inline-option">
<span>Sort by</span>
<DropdownSelect
v-model="searchStore.filter"
@ -291,6 +316,8 @@ watch(
class="sort-dropdown"
@change="getSearchResults"
/>
</div>
<div class="inline-option">
<span>Show per page</span>
<DropdownSelect
v-model="searchStore.limit"
@ -310,19 +337,11 @@ watch(
/>
<SplashScreen v-if="loading" />
<section v-else class="project-list display-mode--list instance-results" role="list">
<ProjectCard
<SearchCard
v-for="result in searchStore.searchResults"
:id="`${result?.project_id}/`"
:key="result?.project_id"
class="result-project-item"
:type="result?.project_type"
:name="result?.title"
:description="result?.description"
:icon-url="result?.icon_url"
:downloads="result?.downloads?.toString()"
:follows="result?.follows?.toString()"
:created-at="result?.date_created"
:updated-at="result?.date_modified"
:project="result"
:instance="searchStore.instanceContext"
:categories="[
...categories.filter(
(cat) =>
@ -335,16 +354,21 @@ watch(
loader.supported_project_types?.includes(searchStore.projectType)
),
]"
:project-type-display="result?.project_type"
project-type-url="project"
:server-side="result?.server_side"
:client-side="result?.client_side"
:show-updated-date="false"
:color="result?.color"
:confirm-modal="confirmModal"
:mod-install-modal="modInstallModal"
:incompatibility-warning-modal="incompatibilityWarningModal"
/>
</section>
<Pagination
:page="searchStore.currentPage"
:count="searchStore.pageCount"
@switch-page="switchPage"
/>
</div>
</div>
<InstallConfirmModal ref="confirmModal" />
<InstanceInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
</template>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
@ -360,39 +384,47 @@ watch(
margin-top: 1rem;
}
.search-panel-container {
.search-panel-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
margin-top: 1rem;
padding: 0.8rem !important;
background-color: var(--color-bg) !important;
margin-bottom: 0;
min-height: min-content !important;
}
.search-panel {
.iconified-input {
input {
max-width: 20rem !important;
}
}
.search-panel-container {
display: inline-flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
width: 100%;
padding: 1rem !important;
white-space: nowrap;
gap: 1rem;
.inline-option {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
width: 100%;
gap: 1rem;
margin: 1rem auto;
white-space: nowrap;
gap: 0.5rem;
.sort-dropdown {
min-width: 12.18rem;
max-width: 12.25rem;
}
.limit-dropdown {
width: 5rem;
}
}
.iconified-input {
width: 75%;
input {
flex-basis: initial;
}
}
.iconified-input {
flex-grow: 1;
}
.filter-panel {
@ -413,13 +445,14 @@ watch(
.filter-panel {
position: fixed;
width: 19rem;
width: 20rem;
background: var(--color-raised-bg);
padding: 1rem;
display: flex;
flex-direction: column;
height: 100%;
max-height: calc(100vh - 3rem);
height: fit-content;
min-height: calc(100vh - 3.25rem);
max-height: calc(100vh - 3.25rem);
overflow-y: auto;
h2 {
@ -432,7 +465,6 @@ watch(
.filter-checkbox {
margin-bottom: 0.3rem;
font-size: 1rem;
text-transform: capitalize;
svg {
display: flex;
@ -447,8 +479,8 @@ watch(
}
.search {
margin: 0 1rem 0 20rem;
width: calc(100% - 21rem);
margin: 0 1rem 0 21rem;
width: calc(100% - 22rem);
.loading {
margin: 2rem;

View File

@ -1,5 +1,5 @@
<script setup>
import { shallowRef } from 'vue'
import { onUnmounted, shallowRef } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { list } from '@/helpers/profile.js'
import { useRoute } from 'vue-router'
@ -14,10 +14,11 @@ breadcrumbs.setRootContext({ name: 'Library', link: route.path })
const profiles = await list(true)
const instances = shallowRef(Object.values(profiles))
profile_listener(async () => {
const unlisten = await profile_listener(async () => {
const profiles = await list(true)
instances.value = Object.values(profiles)
})
onUnmounted(() => unlisten())
</script>
<template>

View File

@ -96,12 +96,6 @@ breadcrumbs.setContext({
link: route.path,
})
profile_listener(async (event) => {
if (event.profile_path === route.params.id) {
instance.value = await get(route.params.id)
}
})
const uuid = ref(null)
const playing = ref(false)
const loading = ref(false)
@ -143,11 +137,20 @@ const stopInstance = async () => {
}
}
const unlisten = await process_listener((e) => {
const unlistenProfiles = await profile_listener(async (event) => {
if (event.path === route.params.id) {
instance.value = await get(route.params.id)
}
})
const unlistenProcesses = await process_listener((e) => {
if (e.event === 'finished' && uuid.value === e.uuid) playing.value = false
})
onUnmounted(() => unlisten())
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
})
</script>
<style scoped lang="scss">

View File

@ -84,10 +84,9 @@ import {
} from 'omorphia'
import { computed, ref, shallowRef } from 'vue'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { useRouter, useRoute } from 'vue-router'
import { useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
const props = defineProps({
instance: {
@ -182,7 +181,7 @@ const updateSort = (projects, sort) => {
}
const searchMod = () => {
router.push({ path: '/browse/mod', query: { instance: route.params.id } })
router.push({ path: '/browse/mod' })
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<Card>
<div class="markdown-body" v-html="renderHighlightedString(project.body)" />
<div class="markdown-body" v-html="renderHighlightedString(project?.body ?? '')" />
</Card>
</template>

View File

@ -187,6 +187,7 @@
</div>
<InstallConfirmModal ref="confirmModal" />
<InstanceInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarning" />
</template>
<script setup>
@ -233,6 +234,7 @@ import InstanceInstallModal from '@/components/ui/InstanceInstallModal.vue'
import Instance from '@/components/ui/Instance.vue'
import { useSearch } from '@/store/search'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import IncompatibilityWarningModal from '@/components/ui/IncompatibilityWarningModal.vue'
const searchStore = useSearch()
@ -242,6 +244,7 @@ const breadcrumbs = useBreadcrumbs()
const confirmModal = ref(null)
const modInstallModal = ref(null)
const incompatibilityWarning = ref(null)
const instance = ref(searchStore.instanceContext)
const installing = ref(false)
@ -267,6 +270,10 @@ watch(
dayjs.extend(relativeTime)
const markInstalled = () => {
installed.value = true
}
async function install(version) {
installing.value = true
let queuedVersionData
@ -308,17 +315,43 @@ async function install(version) {
: true)
)
if (!selectedVersion) {
incompatibilityWarning.value.show(
instance.value,
data.value.title,
versions.value,
markInstalled
)
installing.value = false
return
} else {
queuedVersionData = selectedVersion
await installMod(instance.value.path, selectedVersion.id)
installVersionDependencies(instance.value, queuedVersionData)
}
} else {
const gameVersion = instance.value.metadata.game_version
const loader = instance.value.metadata.loader
const compatible = versions.value.some(
(v) =>
v.game_versions.includes(gameVersion) &&
(data.value.project_type === 'mod'
? v.loaders.includes(loader) || v.loaders.includes('minecraft')
: true)
)
if (compatible) {
await installMod(instance.value.path, queuedVersionData.id)
await installVersionDependencies(instance.value, queuedVersionData)
} else {
incompatibilityWarning.value.show(
instance.value,
data.value.title,
[queuedVersionData],
markInstalled
)
installing.value = false
return
}
queuedVersionData = selectedVersion
await installMod(instance.value.path, selectedVersion.id)
} else {
await installMod(instance.value.path, queuedVersionData.id)
}
await installVersionDependencies(instance.value, queuedVersionData)
installed.value = true
} else {
if (version) {
@ -345,9 +378,9 @@ async function install(version) {
.project-sidebar {
position: fixed;
width: 20rem;
min-height: 100vh;
min-height: calc(100vh - 3.25rem);
height: fit-content;
max-height: 100vh;
max-height: calc(100vh - 3.25rem);
overflow-y: auto;
background: var(--color-raised-bg);
padding: 1rem;

View File

@ -35,14 +35,9 @@
placeholder="Filter versions..."
/>
</div>
<Checkbox
v-model="filterCompatible"
label="Only show compatible versions"
class="filter-checkbox"
/>
<Button
class="no-wrap clear-filters"
:disabled="!filterLoader && !filterVersions && !filterCompatible"
:disabled="!filterLoader && !filterVersions"
:action="clearFilters"
>
<ClearIcon />
@ -129,28 +124,17 @@
</template>
<script setup>
import {
Card,
Button,
CheckIcon,
ClearIcon,
Badge,
DownloadIcon,
Checkbox,
formatNumber,
} from 'omorphia'
import { Card, Button, CheckIcon, ClearIcon, Badge, DownloadIcon, formatNumber } from 'omorphia'
import Multiselect from 'vue-multiselect'
import { releaseColor } from '@/helpers/utils'
import { ref } from 'vue'
let filterVersions = ref(null)
let filterLoader = ref(null)
let filterCompatible = ref(false)
const clearFilters = () => {
filterVersions.value = null
filterLoader.value = null
filterCompatible.value = false
}
defineProps({

View File

@ -20,12 +20,13 @@ export const useSearch = defineStore('searchStore', {
openSource: false,
limit: 20,
instanceContext: null,
ignoreInstance: false,
}),
actions: {
getQueryString() {
let andFacets = [`project_type:${this.projectType === 'datapack' ? 'mod' : this.projectType}`]
if (this.instanceContext) {
if (this.instanceContext && !this.ignoreInstance) {
this.activeVersions = [this.instanceContext.metadata.game_version]
}
@ -42,7 +43,7 @@ export const useSearch = defineStore('searchStore', {
;[...andFacets, `categories:${encodeURIComponent('datapack')}`].forEach(
(f) => (formattedAndFacets += `["${f}"],`)
)
} else if (this.instanceContext && this.projectType === 'mod') {
} else if (this.instanceContext && !this.ignoreInstance && this.projectType === 'mod') {
;[
...andFacets,
`categories:${encodeURIComponent(this.instanceContext.metadata.loader)}`,