feat: app onboarding and library (#6923)

* refactor: remove unused legacy onboarded flag

* sqlx prepare

* feat: add onboarding checklist app db and add vue provider

* feat: add welcome page

* feat: add onboarding checklist component

* feat: move checklist visibility source of truth in app db

* feat: split up import modpack button

* feat-small: style tweak

* feat: remove discover section from homepage and add library

* refactor: move welcome screen to component

* refactor: move grid display into /library

* refactor: rename existing components

* refactor: split up the current library page files

* feat: add create group modal

* feat: add instance group definitions

* feat: update accordian for instance groups

* feat: add delete group

* feat: implement rename instance groups

* refactor: move instance group api out

* feat: add context menu for groups

* feat: style instance items in library

* feat: add selected instance item state

* feat: implement action bar for selection

* feat: drag and drop to move between groups

* feat: show ungrouped with header

* feat: improve edit group name

* small fix

* small style fixes

* feat: improve selection behaviour

* fix: dont toggle group when context menu open

* feat: drag and drop multiple selected

* small

* feat: add remove from group in action bar

* feat: add delay on icon hiding in inline input

* feat: improve icon hiding delay

* fix: bad routes.js

* feat: implement sort and filter options

* refactor: dont prefix filenames

* feat: change open/collapse group target

* feat: improve ungrouped behaviour

* feat: add shift to select multiple instances

* fix: icon disappear transition

* feat: add esc while dragging to cancel

* feat: update sorting and moved label

* feat: update context menu

* fix: multiple context menus can be opened

* refactor: instance groups to be identified by ID instead of name, names become display data

* feat: creating new group always adds instance

* feat: create group button, fix instance selection, and increase group name max length

* feat: strokes

* fix: show getting started checklist in all sidebar

* feat: set size on microsoft login screen

* fix: use ButtonStyled in Chips component

* feat: creating groups with selected instances and fix some moving selection and duplication bugs

* pnpm prepr

* feat: add tooltip for instance already in group

* feat: create instance with content in creation flow modal

- search modpack becomes search projects
- creation flow modal context receives prepareProjectInstall and createProjectInstall callbacks, from content-install.ts (shared with ContentInstallModal)

* feat: update instance card styles

* refactor: separate out instance card content

* update styles

* refactor: instance card

* fix: width of instance

* feat: update empty state

* feat: update sort and grouping dropdowns

* feat: better context menu options

* feat: add group action buttons

* feat: add/remove favourites group

* remove manual sort

* feat: improve the "New group" jank

* feat: add group by instance type and change group sorting

* feat: welcome screen styles match figma

* feat: not signed in skins demo

* feat: new hosting empty state

* feat: new instance card style

* feat: add tooltip and spice up animation more

* feat: improve the transitions for groups after dropping many instances

* feat: set instance group membership for bulk moving

* feat: remove fade out

* fix: allow dragging from buttons inside instance card

* fix: library page load in behaviour

* small fix

* feat: add group ordering and reordering

* feat: add drag to reorder groups

* fix: switching page not clearing selected instances

* style fixes

* style fix

* fix spacing

* feat: jump in changes

* feat: add styled newly created instances

* feat: improve jump in cards styles

* fix: welcome screen style

* fix: empty state styles

* feat: max 5 jump in items

* feat: max new instances to 3

* fix: search icon

* feat: clear selection after move

* feat: skins list use check icon styles

* feat: list will be deleted instances and default sort last played

* fix: stroke

* remove: no more group settings in instance general settings

* fix: resolving modpack server install

* feat: icon editor

* feat: add three hue shifted variants

* fix: surprise me doesnt pick symbol

* feat: add customize icon to create instance modal

* feat: skins loading state style

* fix: welcome logo

* fix: copy

* fix: left over usage of ButtonStyled from main's refactor

* feat: some leaked merge stuff

* feat: server empty state styles

* remove: instance helper text

* remove: auto link in set up stage

* feat: auto focus on search when modal open

* fix: delete instance modal style

* remove: modpack meta repeating

* fix: icon

* fix: font weight

* fix: dropdown filter preview heights

* pnpm prepr

* fix: sign into modrinth flashing change in position

* feat: add avatar transparent corner gets padding

* feat: drag and hold over group opens group

* feat: add random icon for custom instance

* feat: update loading icon

* polish: empty state for search

* polish: jump in cards

* fix: avatar could skip checking for image if tauri loads it first

* feat: add icon customize and randomizer in content install modal for add to new instances

* fix: avatar cannot read image pixels due to cors

* refactor: popup notifications toast type for instance sharing notifications

* feat: apply random icons modal and add popup notification for it

* polish: style

* fix: set_webview_visible

* QA

* qa

* feat: add inner white 15 opacity stroke around avatar

* add to catalog

* pnpm prepr

* fix: app event import

* feat: add new icons and backgrounds for icon editor

* feat: recents list generate from randomized icons and save icon in editor + icon editor modal fixes

* qa

* refactor: recipe -> config

* fix: event bus

* polish

* feat: add safe guard against randomizing empty icons

* feat: navigate to instance after creation

* fix: icon file names

* i18n on welcome screen

* i18n pass on PR

* fix: inconsistent max group name length

* refactor: use sqlx macro

* feat: add instance icon background selection drag

* feat: update catalog order

* fix: remove gradient for welcome screen

* remove: frog

* fix: min width

* feat: update corner size

* fix: context menu styles

* prepr

* fix: instance name trimming

* reorder

* fix: instance card loading state stayed until hovering

* feat: add icon randomizer blacklist

* blog

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Truman Gao
2026-08-18 01:08:09 +00:00
committed by GitHub
co-authored by Prospector
parent d5556271f9
commit e79e0d024b
347 changed files with 12456 additions and 4505 deletions
+36 -10
View File
@@ -33,7 +33,6 @@ import {
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
@@ -45,6 +44,7 @@ import { useAppEvent } from '@/composables/use-app-event'
import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js'
import {
get_installed_project_ids as getInstalledProjectIds,
getInstanceIconUrl,
list as listInstances,
} from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
@@ -182,7 +182,7 @@ const instanceBreadcrumbDefinition = {
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image' as const,
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
src: getInstanceIconUrl(instance.value?.icon_path),
alt: instance.value?.name,
tintBy: String(displayedBrowseRoute.value.query.i ?? ''),
}),
@@ -585,6 +585,17 @@ const messages = defineMessages({
id: 'app.browse.project-type.modpacks',
defaultMessage: 'Modpacks',
},
modsProjectType: { id: 'app.browse.project-type.mods', defaultMessage: 'Mods' },
resourcePacksProjectType: {
id: 'app.browse.project-type.resource-packs',
defaultMessage: 'Resource Packs',
},
dataPacksProjectType: {
id: 'app.browse.project-type.data-packs',
defaultMessage: 'Data Packs',
},
shadersProjectType: { id: 'app.browse.project-type.shaders', defaultMessage: 'Shaders' },
serversProjectType: { id: 'app.browse.project-type.servers', defaultMessage: 'Servers' },
providedByInstance: {
id: 'search.filter.locked.instance',
defaultMessage: 'Provided by the instance',
@@ -687,16 +698,31 @@ const selectableProjectTypes = computed(() => {
}
if (isFromWorlds.value) {
return [{ label: 'Servers', href: `/browse/server${suffix}` }]
return [{ label: formatMessage(messages.serversProjectType), href: `/browse/server${suffix}` }]
}
return [
{ label: 'Modpacks', href: `/browse/modpack${suffix}`, shown: modpacks },
{ label: 'Mods', href: `/browse/mod${suffix}`, shown: mods },
{ label: 'Resource Packs', href: `/browse/resourcepack${suffix}` },
{ label: 'Data Packs', href: `/browse/datapack${suffix}`, shown: dataPacks },
{ label: 'Shaders', href: `/browse/shader${suffix}` },
{ label: 'Servers', href: `/browse/server${suffix}`, shown: !instance.value },
{
label: formatMessage(messages.modpacksProjectType),
href: `/browse/modpack${suffix}`,
shown: modpacks,
},
{ label: formatMessage(messages.modsProjectType), href: `/browse/mod${suffix}`, shown: mods },
{
label: formatMessage(messages.resourcePacksProjectType),
href: `/browse/resourcepack${suffix}`,
},
{
label: formatMessage(messages.dataPacksProjectType),
href: `/browse/datapack${suffix}`,
shown: dataPacks,
},
{ label: formatMessage(messages.shadersProjectType), href: `/browse/shader${suffix}` },
{
label: formatMessage(messages.serversProjectType),
href: `/browse/server${suffix}`,
shown: !instance.value,
},
]
})
@@ -730,7 +756,7 @@ const installContext = computed(() => {
name: instance.value.name,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
iconSrc: getInstanceIconUrl(instance.value.icon_path),
backUrl: `/instance/${encodeURIComponent(instance.value.id)}${isFromWorlds.value ? '/worlds' : ''}`,
backLabel: formatMessage(messages.backToInstance),
heading: formatMessage(
+79 -87
View File
@@ -1,125 +1,117 @@
<script setup lang="ts">
import { HomeIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import { HomeIcon, PlusIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { computed, inject, onActivated, ref } from 'vue'
import RowDisplay from '@/components/RowDisplay.vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import LibrarySection from '@/components/ui/library/index.vue'
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { get_search_results } from '@/helpers/cache.js'
import { toError } from '@/helpers/errors'
import { list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { injectOnboardingChecklist } from '@/providers/onboarding-checklist'
import { useTheming } from '@/store/theme.ts'
defineOptions({
name: 'LibraryPage',
})
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { hasCreatedInstance, isReady } = injectOnboardingChecklist()
const showCreationModal = inject<() => void>('showCreationModal')
const pageOptions = ref<InstanceType<typeof ContextMenu>>()
const themeStore = useTheming()
useRootBreadcrumb({
const messages = defineMessages({
home: {
id: 'app.navigation.home',
defaultMessage: 'Home',
},
newInstance: {
id: 'app.library.context-menu.create-instance',
defaultMessage: 'New instance',
},
})
const homeBreadcrumb = useRootBreadcrumb({
slot: 'root',
id: 'home',
label: 'Home',
label: formatMessage(messages.home),
to: '/',
visual: { type: 'icon', component: HomeIcon },
})
onActivated(homeBreadcrumb.reset)
const instances = ref<GameInstance[]>([])
const featuredModpacks = ref<SearchResult[]>([])
const featuredMods = ref<SearchResult[]>([])
const installedModpacksFilter = ref('')
let latestInstanceFetch = 0
const recentInstances = computed(() =>
instances.value
.filter((x) => x.last_played)
.slice()
.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))),
.sort((a, b) => dayjs(b.last_played ?? b.created).diff(dayjs(a.last_played ?? a.created))),
)
const hasFeaturedProjects = computed(
() => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0,
)
const offline = ref<boolean>(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
async function fetchInstances() {
instances.value = await list().catch(handleError)
const filters = []
for (const instance of instances.value) {
if (instance.link && instance.link.project_id) {
filters.push(`NOT"project_id"="${instance.link.project_id}"`)
const fetchId = ++latestInstanceFetch
try {
const nextInstances = await list()
if (fetchId === latestInstanceFetch) {
instances.value = nextInstances
}
} catch (error: unknown) {
if (fetchId === latestInstanceFetch) {
handleError(toError(error))
}
}
installedModpacksFilter.value = filters.join(' AND ')
}
async function fetchFeaturedModpacks() {
const response = await get_search_results(
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`,
)
if (response) {
featuredModpacks.value = response.result.hits
} else {
featuredModpacks.value = []
}
}
async function fetchFeaturedMods() {
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
if (response) {
featuredMods.value = response.result.hits
} else {
featuredModpacks.value = []
}
}
async function refreshFeaturedProjects() {
await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()])
}
await fetchInstances()
await refreshFeaturedProjects()
useAppEvent('instance', async (event) => {
if (hasCreatedInstance.value) {
await fetchInstances()
}
if (event.event === 'created' || event.event === 'removed') {
await refreshFeaturedProjects()
useAppEvent('instance', fetchInstances)
useAppEvent('instance_groups_changed', fetchInstances)
function openPageContextMenu(event: MouseEvent) {
if (
!(event.target instanceof HTMLElement) ||
!event.target.hasAttribute('data-library-page-background')
) {
return
}
})
event.preventDefault()
event.stopPropagation()
pageOptions.value?.showMenu(event, {}, [{ name: 'new_instance' }])
}
function handlePageOption({ option }: { option: string }) {
if (option === 'new_instance') {
showCreationModal?.()
}
}
</script>
<template>
<div class="p-6 flex flex-col gap-2">
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
<RecentWorldsList :recent-instances="recentInstances" />
<RowDisplay
v-if="hasFeaturedProjects"
:instances="[
{
label: 'Discover a modpack',
route: '/browse/modpack',
instances: featuredModpacks,
downloaded: false,
},
{
label: 'Discover mods',
route: '/browse/mod',
instances: featuredMods,
downloaded: false,
},
]"
:can-paginate="true"
<WelcomeScreen v-if="isReady && !hasCreatedInstance" />
<div
v-else-if="isReady"
data-library-page-background
class="flex flex-col gap-6 p-6"
@contextmenu="openPageContextMenu"
>
<RecentWorldsList
v-if="recentInstances?.length > 0 && themeStore.getFeatureFlag('worlds_in_home')"
:recent-instances="recentInstances"
/>
<LibrarySection :instances="instances" />
<ContextMenu ref="pageOptions" @option-clicked="handlePageOption">
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
</ContextMenu>
</div>
</template>
+48 -54
View File
@@ -2,12 +2,12 @@
import {
CheckIcon,
EditIcon,
ExcitedRinthbot,
EyeIcon,
LogInIcon,
InfoIcon,
RotateCounterClockwiseIcon,
ShirtIcon,
SpinnerIcon,
WindowsIcon,
} from '@modrinth/assets'
import {
Button,
@@ -173,6 +173,10 @@ const messages = defineMessages({
id: 'app.skins.apply-button',
defaultMessage: 'Apply',
},
demoApplyTooltip: {
id: 'app.skins.demo.apply-tooltip',
defaultMessage: 'Sign in to apply skins.',
},
editSkinButton: {
id: 'app.skins.preview.edit-button',
defaultMessage: 'Edit skin',
@@ -189,22 +193,17 @@ const messages = defineMessages({
id: 'app.skins.toggle-ears-features-on',
defaultMessage: 'Toggle on',
},
excitedRinthbotAlt: {
id: 'app.skins.sign-in.rinthbot-alt',
defaultMessage: 'Excited Modrinth Bot',
demoTitle: {
id: 'app.skins.demo.title',
defaultMessage: 'Editing with a demo account',
},
signInTitle: {
id: 'app.skins.sign-in.title',
defaultMessage: 'Please sign in',
},
signInDescription: {
id: 'app.skins.sign-in.description',
defaultMessage:
'Please sign into your Minecraft account to use the skin management features of the Modrinth app.',
demoDescription: {
id: 'app.skins.demo.description',
defaultMessage: 'Sign in to your Minecraft account to save and apply skins!',
},
signInButton: {
id: 'app.skins.sign-in.button',
defaultMessage: 'Sign In',
defaultMessage: 'Sign in to Microsoft',
},
})
@@ -342,7 +341,9 @@ const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
const isSkinManagementReadOnly = computed(
() => offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value),
() =>
!!currentUser.value &&
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value)),
)
const hasPendingSkinChange = computed(
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
@@ -746,6 +747,7 @@ function schedulePendingSkinRefresh() {
async function applySelectedSkin() {
const skinToApply = selectedSkin.value
if (
!currentUser.value ||
!skinToApply ||
!hasPendingSkinChange.value ||
isApplyingSkin.value ||
@@ -1058,6 +1060,7 @@ await loadSkins()
<EditSkinModal
ref="editSkinModal"
:capes="capes"
:demo="!currentUser"
@saved="onSkinSaved"
@deleted="() => loadSkins()"
/>
@@ -1076,7 +1079,7 @@ await loadSkins()
@proceed="deleteSkin"
/>
<div v-if="currentUser" class="skin-layout box-border min-h-full p-4">
<div class="skin-layout box-border grow p-4" :class="{ 'pb-40': !currentUser }">
<div class="sticky top-6 self-start p-2 pt-0">
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
{{ formatMessage(appMessages.skinSelectorLabel) }}
@@ -1132,13 +1135,17 @@ await loadSkins()
</Button>
<Button
v-tooltip="
selectedSkinHasEarsFeatures ? formatMessage(messages.applyButton) : undefined
!currentUser
? formatMessage(messages.demoApplyTooltip)
: selectedSkinHasEarsFeatures
? formatMessage(messages.applyButton)
: undefined
"
type="colored"
color="brand"
size="lg"
class="skin-preview-action-button"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
:disabled="!currentUser || isApplyingSkin || isSkinManagementReadOnly"
:aria-label="formatMessage(messages.applyButton)"
@click="applySelectedSkin"
>
@@ -1258,45 +1265,32 @@ await loadSkins()
</div>
</div>
<div v-else class="box-border flex min-h-full items-center justify-center pt-[25%]">
<div v-if="!currentUser" class="sticky w-full bottom-0 z-20 p-4 pt-0">
<div
class="relative mx-auto flex w-full max-w-xl flex-col gap-5 rounded-lg bg-bg-raised p-7 shadow-lg"
class="mx-auto flex w-full max-w-5xl items-center justify-between gap-3 rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-4"
>
<img
:src="ExcitedRinthbot"
:alt="formatMessage(messages.excitedRinthbotAlt)"
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
/>
<div
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
style="
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
"
></div>
<div class="flex flex-col gap-5">
<h1 class="text-3xl font-extrabold m-0">{{ formatMessage(messages.signInTitle) }}</h1>
<p class="text-lg m-0">
{{ formatMessage(messages.signInDescription) }}
</p>
<Button
v-show="accountsCard"
type="colored"
color="brand"
:disabled="accountsCard.loginDisabled"
@click="login"
>
<LogInIcon v-if="!accountsCard.loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInButton) }}
</Button>
<div class="flex min-w-0 grow items-start gap-3">
<InfoIcon class="size-6 shrink-0 text-blue" />
<div class="flex min-w-0 flex-col gap-1">
<p class="m-0 text-lg font-semibold leading-6 text-contrast">
{{ formatMessage(messages.demoTitle) }}
</p>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.demoDescription) }}
</p>
</div>
</div>
<Button
v-show="accountsCard"
type="colored"
color="brand"
:disabled="accountsCard.loginDisabled"
@click="login"
>
<SpinnerIcon v-if="accountsCard.loginDisabled" class="animate-spin" />
<WindowsIcon v-else />
{{ formatMessage(messages.signInButton) }}
</Button>
</div>
</div>
</template>
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import {
CopyIcon,
EditIcon,
PaletteIcon,
SpinnerIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
Checkbox,
Chips,
defineMessages,
injectNotificationManager,
@@ -12,17 +18,17 @@ import {
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, list, remove } from '@/helpers/instance'
import { edit, edit_icon, getInstanceIconUrl, remove } from '@/helpers/instance'
import type { GameInstance, InstanceIconConfig } from '@/helpers/types'
import type { GameInstance } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
@@ -31,6 +37,7 @@ const router = useRouter()
const queryClient = useQueryClient()
const deleteConfirmModal = ref()
const iconEditorModal = ref<InstanceType<typeof IconEditorModal> | null>(null)
const { instance } = injectInstanceSettings()
type ReleaseChannel = GameInstance['update_channel']
@@ -38,17 +45,23 @@ const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
const title = ref(instance.value.name)
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
const groups = ref([...instance.value.groups])
const iconConfig = ref<InstanceIconConfig | null>(instance.value.icon_config ?? null)
const savingReleaseChannel = ref(false)
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.update_channel)
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
)
const newCategoryInput = ref('')
const installing = computed(() => instance.value.install_stage !== 'installed')
watch(
() => [instance.value.id, instance.value.icon_path, instance.value.icon_config] as const,
() => {
icon.value = instance.value.icon_path
iconConfig.value = instance.value.icon_config ?? null
},
)
async function duplicateInstance() {
await install_duplicate_instance(instance.value.id).catch(handleError)
trackEvent('InstanceDuplicate', {
@@ -57,11 +70,6 @@ async function duplicateInstance() {
})
}
const allInstances = ref((await list()) as GameInstance[])
const availableGroups = computed(() => [
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
])
function formatReleaseChannelLabel(channel: ReleaseChannel) {
switch (channel) {
case 'release':
@@ -112,10 +120,12 @@ async function resetIcon() {
try {
await edit_icon(instance.value.id, null)
icon.value = undefined
trackEvent('InstanceRemoveIcon')
iconConfig.value = null
} catch (error) {
handleError(error)
return
}
trackEvent('InstanceRemoveIcon')
}
async function setIcon() {
@@ -134,36 +144,32 @@ async function setIcon() {
try {
await edit_icon(instance.value.id, value)
icon.value = value
trackEvent('InstanceSetIcon')
iconConfig.value = null
} catch (error) {
handleError(error)
return
}
trackEvent('InstanceSetIcon')
}
function openIconEditor() {
iconEditorModal.value?.show()
trackEvent(iconConfig.value ? 'InstanceEditCreatedIcon' : 'InstanceCreateIcon')
}
function onGeneratedIconSaved(iconPath: string, config: InstanceIconConfig) {
icon.value = iconPath
iconConfig.value = config
trackEvent('InstanceSaveCreatedIcon')
}
const editInstanceObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
name: title.value.trim().substring(0, 80) ?? 'Instance',
}))
const toggleGroup = (group: string) => {
if (groups.value.includes(group)) {
groups.value = groups.value.filter((x) => x !== group)
} else {
groups.value.push(group)
}
}
const addCategory = () => {
const text = newCategoryInput.value.trim()
if (text.length > 0) {
groups.value.push(text.substring(0, 32))
newCategoryInput.value = ''
}
}
watch(
[title, groups, groups],
title,
async () => {
if (removing.value) return
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
@@ -190,22 +196,9 @@ const messages = defineMessages({
id: 'instance.settings.tabs.general.name',
defaultMessage: 'Name',
},
libraryGroups: {
id: 'instance.settings.tabs.general.library-groups',
defaultMessage: 'Library groups',
},
libraryGroupsDescription: {
id: 'instance.settings.tabs.general.library-groups.description',
defaultMessage:
'Library groups allow you to organize your instances into different sections in your library.',
},
libraryGroupsEnterName: {
id: 'instance.settings.tabs.general.library-groups.enter-name',
defaultMessage: 'Enter group name',
},
libraryGroupsCreate: {
id: 'instance.settings.tabs.general.library-groups.create',
defaultMessage: 'Create new group',
icon: {
id: 'instance.settings.tabs.general.icon',
defaultMessage: 'Icon',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
@@ -219,6 +212,14 @@ const messages = defineMessages({
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
createIcon: {
id: 'instance.settings.tabs.general.edit-icon.create',
defaultMessage: 'Create an icon',
},
editCreatedIcon: {
id: 'instance.settings.tabs.general.edit-icon.edit-created',
defaultMessage: 'Edit icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
@@ -292,11 +293,23 @@ const messages = defineMessages({
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeInstance" />
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:instances="[instance]"
@delete="removeInstance"
/>
<IconEditorModal
ref="iconEditorModal"
:instance-id="instance.id"
:config="iconConfig"
@saved="onGeneratedIconSaved"
/>
<div class="block">
<div class="float-end ml-10 relative group w-fit">
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Icon</span>
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.icon) }}
</span>
<div class="group relative w-fit">
<TeleportOverflowMenu
:label="formatMessage(messages.editIcon)"
@@ -313,6 +326,10 @@ const messages = defineMessages({
: formatMessage(messages.selectIcon),
action: () => setIcon(),
},
{
id: 'create',
action: () => openIconEditor(),
},
{
id: 'remove',
label: formatMessage(messages.removeIcon),
@@ -322,7 +339,7 @@ const messages = defineMessages({
]"
>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
:src="getInstanceIconUrl(icon)"
size="108px"
class="transition-[filter] group-hover:brightness-75"
:tint-by="instance.id"
@@ -331,12 +348,16 @@ const messages = defineMessages({
<div
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
>
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
<EditIcon aria-hidden="true" class="h-10 w-10 text-white opacity-70" />
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #create>
<PaletteIcon />
{{ formatMessage(iconConfig ? messages.editCreatedIcon : messages.createIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</TeleportOverflowMenu>
</div>
@@ -373,36 +394,6 @@ const messages = defineMessages({
</p>
</div>
</template>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newCategoryInput"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
class="w-full max-w-[300px]"
@submit="() => addCategory"
/>
<Button class="w-fit" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</Button>
</div>
</div>
<p class="m-0">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.updateChannel) }}
@@ -19,11 +19,10 @@ import {
} from '@modrinth/ui'
import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, nextTick, ref, watch } from 'vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
import { get_linked_modpack_info, getInstanceIconUrl } from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
@@ -198,7 +197,7 @@ defineExpose({ show, hide })
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:src="getInstanceIconUrl(instance.icon_path)"
size="24px"
:tint-by="props.instance.id"
/>
@@ -117,7 +117,6 @@ import {
versionChangesGameVersion,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -135,6 +134,7 @@ import {
edit,
get_linked_modpack_content,
get_shared_instance_publish_preview,
getInstanceIconUrl,
is_file_on_modrinth,
remove_project,
set_project_locked,
@@ -319,7 +319,7 @@ const localImportedModpackProject = computed<ManagedContentProject | null>(() =>
id: link.filename ?? instance.value.id,
slug: link.filename ?? instance.value.id,
title: link.name ?? instance.value.name,
icon_url: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
icon_url: getInstanceIconUrl(instance.value.icon_path) ?? undefined,
filename: link.filename ?? undefined,
}
})
+79 -33
View File
@@ -5,7 +5,11 @@
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
>
<ExportModal v-if="!instance.quarantined" ref="exportModal" :instance="instance" />
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteSelectedInstance" />
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:instances="selectedInstanceToDelete ? [selectedInstanceToDelete] : []"
@delete="deleteSelectedInstance"
/>
<InstanceSettingsModal
:key="instance.id"
ref="settingsModal"
@@ -45,7 +49,7 @@
@play-server="() => handlePlayServer()"
@settings="() => settingsModal?.show()"
@open-folder="() => instance && showInstanceInFolder(instance.id)"
@export="() => !instance.quarantined && exportModal?.show()"
@export="() => !instance?.quarantined && exportModal?.show()"
@create-shortcut="() => createShortcut()"
@report="reportSharedInstance"
/>
@@ -79,12 +83,14 @@
</RouterView>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EditIcon /> {{ formatMessage(messages.edit) }} </template>
<template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
<template #open_folder>
<FolderOpenIcon /> {{ formatMessage(messages.openFolder) }}
</template>
</ContextMenu>
</div>
</template>
@@ -103,17 +109,17 @@ import {
} from '@modrinth/assets'
import {
commonMessages,
defineMessages,
injectNotificationManager,
NavTabs,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useOnline } from '@vueuse/core'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, type ComputedRef, onUnmounted, ref, watch } from 'vue'
import { computed, type ComputedRef, onUnmounted, ref, shallowRef, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -129,6 +135,7 @@ import {
import { useAppEvent } from '@/composables/use-app-event'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { toError } from '@/helpers/errors'
import {
getSharedInstanceUnavailableReason,
install_existing_instance,
@@ -137,7 +144,14 @@ import {
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import { get_full_path, kill, refresh_content_updates, remove, run } from '@/helpers/instance'
import {
get_full_path,
getInstanceIconUrl,
kill,
refresh_content_updates,
remove,
run,
} from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
@@ -169,13 +183,45 @@ const queryClient = useQueryClient()
const route = useRoute()
const { formatMessage } = useVIntl()
const messages = defineMessages({
play: { id: 'app.instance.action.play', defaultMessage: 'Play' },
stop: { id: 'app.instance.action.stop', defaultMessage: 'Stop' },
addContent: { id: 'app.instance.action.add-content', defaultMessage: 'Add content' },
edit: { id: 'app.instance.action.edit', defaultMessage: 'Edit' },
copyPath: { id: 'app.instance.action.copy-path', defaultMessage: 'Copy path' },
openFolder: { id: 'app.instance.action.open-folder', defaultMessage: 'Open folder' },
contentTab: { id: 'app.instance.tab.content', defaultMessage: 'Content' },
filesTab: { id: 'app.instance.tab.files', defaultMessage: 'Files' },
worldsTab: { id: 'app.instance.tab.worlds', defaultMessage: 'Worlds' },
logsTab: { id: 'app.instance.tab.logs', defaultMessage: 'Logs' },
shareTab: { id: 'app.instance.tab.share', defaultMessage: 'Share' },
shortcutCreated: {
id: 'app.instance.shortcut.created',
defaultMessage: 'Shortcut created',
},
shortcutCreationError: {
id: 'app.instance.shortcut.creation-error',
defaultMessage: 'Error creating shortcut',
},
})
const router = useRouter()
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/instance/')) {
displayedInstanceRoute.value = nextRoute
}
},
{ immediate: true },
)
const themeStore = useTheming()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
const online = useOnline()
const offline = computed(() => !online.value)
const instanceId = ref(String(route.params.id ?? ''))
const instanceId = computed(() => String(displayedInstanceRoute.value.params.id ?? ''))
const instanceQuery = useQuery(
computed(() => ({
...instanceDetailQueryOptions(instanceId.value),
@@ -184,7 +230,7 @@ const instanceQuery = useQuery(
)
useQuery(
computed(() => ({
...instanceContentQueryOptions(instanceId.value, (error) => handleError(error)),
...instanceContentQueryOptions(instanceId.value, (error) => handleError(toError(error))),
enabled: !!instanceId.value,
})),
)
@@ -227,7 +273,7 @@ const playing = computed(() => (processesQuery.data.value?.length ?? 0) > 0)
async function ensureCriticalContent(targetInstanceId: string) {
await queryClient.ensureQueryData(
instanceContentQueryOptions(targetInstanceId, (error) => handleError(error)),
instanceContentQueryOptions(targetInstanceId, (error) => handleError(toError(error))),
)
}
@@ -246,7 +292,7 @@ try {
await ensureCriticalInstanceData(instanceId.value)
} catch (error) {
if (isUnmanagedInstanceError(error)) await router.replace('/')
else handleError(error)
else handleError(toError(error))
}
onBeforeRouteUpdate(async (to, from) => {
@@ -259,7 +305,7 @@ onBeforeRouteUpdate(async (to, from) => {
instanceId.value = targetInstanceId
} catch (error) {
if (isUnmanagedInstanceError(error)) return { path: '/' }
handleError(error)
handleError(toError(error))
return false
}
})
@@ -270,7 +316,7 @@ useRootBreadcrumb({
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
src: getInstanceIconUrl(instance.value?.icon_path),
alt: instance.value?.name,
tintBy: instance.value?.id ?? instanceId.value,
}),
@@ -391,14 +437,14 @@ watch(
(error) => {
if (!error) return
if (error.message.includes('is not managed')) void router.replace('/')
else handleError(error)
else handleError(toError(error))
},
{ immediate: true },
)
watch(
linkedProjectQuery.error,
(error) => {
if (error) handleError(error)
if (error) handleError(toError(error))
},
{ immediate: true },
)
@@ -433,22 +479,22 @@ const showShareTab = computed(() => {
const tabs = computed(() => {
const instanceTabs = [
{
label: 'Content',
label: formatMessage(messages.contentTab),
href: `${basePath.value}`,
icon: BoxesIcon,
},
{
label: 'Files',
label: formatMessage(messages.filesTab),
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
label: formatMessage(messages.worldsTab),
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
label: formatMessage(messages.logsTab),
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
@@ -456,7 +502,7 @@ const tabs = computed(() => {
if (showShareTab.value) {
instanceTabs.push({
label: 'Share',
label: formatMessage(messages.shareTab),
href: `${basePath.value}/share`,
icon: UserPlusIcon,
})
@@ -593,7 +639,7 @@ const stopInstance = async (context: string) => {
const currentInstance = instance.value
if (!currentInstance) return
stopping.value = true
await kill(currentInstance.id).catch(handleError)
await kill(currentInstance.id).catch((error) => handleError(toError(error)))
stopping.value = false
queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [])
@@ -649,9 +695,11 @@ const repairInstance = async () => {
project_id: currentInstance.link.project_id ?? currentInstance.link.server_project_id ?? '',
version_id: currentInstance.link.version_id ?? currentInstance.link.content_version_id ?? '',
title: currentInstance.name,
}).catch(handleError)
}).catch((error) => handleError(toError(error)))
} else {
await install_existing_instance(currentInstance.id, false).catch(handleError)
await install_existing_instance(currentInstance.id, false).catch((error) =>
handleError(toError(error)),
)
}
}
@@ -663,12 +711,12 @@ const createShortcut = async () => {
addNotification({
type: 'success',
title: 'Shortcut created',
title: formatMessage(messages.shortcutCreated),
})
} catch (error: unknown) {
addNotification({
type: 'error',
title: `Error creating shortcut`,
title: formatMessage(messages.shortcutCreationError),
text: `${error}`,
})
}
@@ -713,7 +761,7 @@ async function deleteSelectedInstance() {
game_version: selectedInstance.game_version,
})
await router.push({ path: '/' })
await remove(selectedInstance.id).catch(handleError)
await remove(selectedInstance.id).catch((error) => handleError(toError(error)))
}
const handleRightClick = (event: MouseEvent) => {
@@ -831,9 +879,7 @@ useAppEvent('process', (event) => {
}
})
const icon = computed(() =>
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
const icon = computed(() => getInstanceIconUrl(instance.value?.icon_path))
const timePlayed = computed(() => {
return instance.value
@@ -1,17 +0,0 @@
<script setup lang="ts">
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay
v-if="instances && instances.length > 0"
label="Instances"
:instances="instances.filter((i) => !i.link)"
/>
</template>
@@ -1,17 +0,0 @@
<script setup lang="ts">
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay
v-if="instances && instances.length > 0"
label="Instances"
:instances="instances.filter((i) => i.link)"
/>
</template>
@@ -1,95 +0,0 @@
<script setup lang="ts">
import { LibraryIcon, PlusIcon } from '@modrinth/assets'
import { Button, injectNotificationManager, NavTabs } from '@modrinth/ui'
import { inject, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import { useAppEvent } from '@/composables/use-app-event'
import { list } from '@/helpers/instance'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const { handleError } = injectNotificationManager()
const showCreationModal = inject('showCreationModal')
const route = useRoute()
useRootBreadcrumb({
slot: 'root',
id: 'library',
label: 'Library',
to: '/library',
visual: { type: 'icon', component: LibraryIcon },
})
const instances = shallowRef(await list().catch(handleError))
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
useAppEvent('instance', async () => {
instances.value = await list().catch(handleError)
})
</script>
<template>
<div class="p-6 flex flex-col gap-3">
<h1 class="m-0 text-2xl hidden">Library</h1>
<div class="flex items-center justify-between gap-3">
<NavTabs
:links="[
{ label: 'All instances', href: `/library` },
{ label: 'Modpacks', href: `/library/modpacks` },
{ label: 'Servers', href: `/library/servers` },
{ label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<Button type="colored" color="brand" :disabled="offline" @click="showCreationModal?.()">
<PlusIcon />
New instance
</Button>
</div>
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance">
<div class="icon">
<NewInstanceImage />
</div>
<h3>No instances found</h3>
<Button type="colored" color="brand" :disabled="offline" @click="showCreationModal?.()">
<PlusIcon />
Create new instance
</Button>
</div>
</div>
</template>
<style lang="scss" scoped>
.no-instance {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--gap-md);
p,
h3 {
margin: 0;
}
.icon {
svg {
width: 10rem;
height: 10rem;
}
}
}
</style>
@@ -1,47 +0,0 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { get_project_v3_many } from '@/helpers/cache.js'
const props = defineProps({
instances: {
type: Array,
required: true,
},
})
const serverProjectIds = ref(new Set())
const linkedInstances = computed(() => props.instances.filter((i) => i.link))
watchEffect(async () => {
const projectIds = [
...new Set(linkedInstances.value.map((i) => i.link?.project_id).filter(Boolean)),
]
if (projectIds.length === 0) {
serverProjectIds.value = new Set()
return
}
try {
const projects = await get_project_v3_many(projectIds, 'must_revalidate')
serverProjectIds.value = new Set(
projects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
)
} catch {
serverProjectIds.value = new Set()
}
})
const filteredInstances = computed(() =>
linkedInstances.value.filter((i) => !serverProjectIds.value.has(i.link?.project_id)),
)
</script>
<template>
<GridDisplay
v-if="filteredInstances && filteredInstances.length > 0"
label="Instances"
:instances="filteredInstances"
/>
</template>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import GridDisplay from '@/components/GridDisplay.vue'
defineProps({
instances: {
type: Array,
required: true,
},
})
</script>
<template>
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
</template>
@@ -1,47 +0,0 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { get_project_v3_many } from '@/helpers/cache.js'
const props = defineProps({
instances: {
type: Array,
required: true,
},
})
const serverProjectIds = ref(new Set())
const linkedInstances = computed(() => props.instances.filter((i) => i.link))
watchEffect(async () => {
const projectIds = [
...new Set(linkedInstances.value.map((i) => i.link?.project_id).filter(Boolean)),
]
if (projectIds.length === 0) {
serverProjectIds.value = new Set()
return
}
try {
const projects = await get_project_v3_many(projectIds, 'must_revalidate')
serverProjectIds.value = new Set(
projects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
)
} catch {
serverProjectIds.value = new Set()
}
})
const filteredInstances = computed(() =>
linkedInstances.value.filter((i) => serverProjectIds.value.has(i.link?.project_id)),
)
</script>
<template>
<GridDisplay
v-if="filteredInstances && filteredInstances.length > 0"
label="Instances"
:instances="filteredInstances"
/>
</template>
@@ -1,8 +0,0 @@
import Custom from './Custom.vue'
import Downloaded from './Downloaded.vue'
import Index from './Index.vue'
import Modpacks from './Modpacks.vue'
import Overview from './Overview.vue'
import Servers from './Servers.vue'
export { Custom, Downloaded, Index, Modpacks, Overview, Servers }
+19 -10
View File
@@ -109,7 +109,7 @@
<TeleportOverflowMenu
type="quiet"
size="xl"
label="More options"
:label="formatMessage(messages.moreOptions)"
:options="serverProjectHeaderMoreActions"
>
<MoreVerticalIcon />
@@ -163,7 +163,7 @@
<TeleportOverflowMenu
type="quiet"
size="xl"
label="More options"
:label="formatMessage(messages.moreOptions)"
:options="projectHeaderMoreActions"
>
<MoreVerticalIcon />
@@ -174,17 +174,17 @@
<NavTabs
:links="[
{
label: 'Description',
label: formatMessage(messages.descriptionTab),
href: projectDescriptionHref,
},
{
label: 'Versions',
label: formatMessage(messages.versionsTab),
href: versionsHref,
subpages: ['version'],
shown: projectV3?.minecraft_server == null,
},
{
label: 'Gallery',
label: formatMessage(messages.galleryTab),
href: projectGalleryHref,
shown: data.gallery.length > 0,
},
@@ -202,7 +202,7 @@
:installed-version="installedVersion"
/>
</template>
<template v-else> Project data couldn't not be loaded. </template>
<template v-else>{{ formatMessage(messages.loadError) }}</template>
</div>
<SelectedProjectsFloatingBar
v-if="projectInstallContext"
@@ -280,7 +280,6 @@ import {
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -306,6 +305,7 @@ import {
import {
get as getInstance,
get_projects as getInstanceProjects,
getInstanceIconUrl,
kill,
list as listInstances,
} from '@/helpers/instance'
@@ -352,6 +352,15 @@ const themeStore = useTheming()
const { formatMessage } = useVIntl()
const messages = defineMessages({
moreOptions: { id: 'app.project.more-options', defaultMessage: 'More options' },
descriptionTab: { id: 'app.project.tab.description', defaultMessage: 'Description' },
versionsTab: { id: 'app.project.tab.versions', defaultMessage: 'Versions' },
galleryTab: { id: 'app.project.tab.gallery', defaultMessage: 'Gallery' },
loadError: {
id: 'app.project.load-error',
defaultMessage: 'Project data could not be loaded.',
},
comingSoon: { id: 'app.project.coming-soon', defaultMessage: 'Coming soon' },
backToBrowse: {
id: 'app.project.install-context.back-to-browse',
defaultMessage: 'Back to discover',
@@ -530,7 +539,7 @@ const projectInstallContext = computed(() => {
name: instance.value.name,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
iconSrc: getInstanceIconUrl(instance.value.icon_path),
backUrl: projectBrowseBackUrl.value,
backLabel: projectBackLabel.value,
heading: formatMessage(commonMessages.installingContentLabel),
@@ -605,7 +614,7 @@ const projectHeaderMoreActions = computed(() => [
label: formatMessage(commonMessages.followButton),
icon: HeartIcon,
disabled: true,
tooltip: 'Coming soon',
tooltip: formatMessage(messages.comingSoon),
action: () => {},
},
{
@@ -613,7 +622,7 @@ const projectHeaderMoreActions = computed(() => [
label: formatMessage(commonMessages.saveButton),
icon: BookmarkIcon,
disabled: true,
tooltip: 'Coming soon',
tooltip: formatMessage(messages.comingSoon),
action: () => {},
},
{