mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+76
-85
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
@@ -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: () => {},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user