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
+1
View File
@@ -14,6 +14,7 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@dnd-kit/vue": "^0.5.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
+157 -48
View File
@@ -13,11 +13,10 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
HomeIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
@@ -57,7 +56,7 @@ import {
import { renderString } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -74,6 +73,7 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
@@ -82,6 +82,9 @@ import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyIn
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import NewIconEditorNotification from '@/components/ui/new-icon-editor-notification/index.vue'
import { shouldShowNewIconEditorNotification } from '@/components/ui/new-icon-editor-notification/show-notification'
import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
@@ -248,7 +251,7 @@ useAppEvent(
'warning',
(event) =>
addNotification({
title: 'Warning',
title: formatMessage(messages.warning),
text: event.message,
type: 'warning',
}),
@@ -332,19 +335,68 @@ provideModalBehavior({
onHide: () => release_ads_window_hold(),
})
const creationIconEditorModal = ref(null)
const creationGeneratedIcon = ref(null)
const creationIconTarget = ref('creation-flow')
const {
installationModal,
unknownPackWarningModal,
fetchExistingInstanceNames,
handleCreate,
handleBrowseModpacks,
searchModpacks,
getProjectVersions,
searchProjects,
getLoaderManifest,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,
} = setupProviders(tauriApiClient, notificationManager, popupNotificationManager)
onboardingChecklist,
} = setupProviders(
tauriApiClient,
notificationManager,
popupNotificationManager,
appEvents,
(iconPath) =>
creationGeneratedIcon.value?.path === iconPath ? creationGeneratedIcon.value.config : null,
)
const { hasLoggedIntoMinecraft, hasLoggedIntoModrinth, showChecklist } = onboardingChecklist
const showFriendsList = computed(() => !showChecklist.value || hasLoggedIntoModrinth.value)
async function randomizeCreationIcon() {
const generated = await creationIconEditorModal.value?.randomizeAndSave()
if (!generated) return null
creationGeneratedIcon.value = { path: generated.iconPath, config: generated.config }
return {
path: generated.iconPath,
previewUrl: convertFileSrc(generated.iconPath),
}
}
function customizeCreationIcon() {
creationIconTarget.value = 'creation-flow'
creationIconEditorModal.value?.show()
}
function customizeContentInstallIcon() {
creationIconTarget.value = 'content-install'
creationIconEditorModal.value?.show()
}
function onCreationIconSaved(iconPath, config) {
creationGeneratedIcon.value = { path: iconPath, config }
if (creationIconTarget.value === 'content-install') {
modInstallModal.value?.setIcon(iconPath, convertFileSrc(iconPath))
return
}
const context = installationModal.value?.ctx
if (!context) return
context.instanceIcon.value = null
context.instanceIconUrl.value = convertFileSrc(iconPath)
context.instanceIconPath.value = iconPath
}
const news = ref([])
const displayedServerInviteNotifications = new Set()
@@ -360,7 +412,6 @@ window.addEventListener('online', () => {
offline.value = false
})
const showOnboarding = ref(false)
const nativeDecorations = ref(false)
const os = ref('')
@@ -425,6 +476,15 @@ const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const messages = defineMessages({
warning: { id: 'app.notification.warning', defaultMessage: 'Warning' },
moreOptions: { id: 'app.navigation.more-options', defaultMessage: 'More options' },
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
updateDownloadMissingVersion: {
id: 'app.update.download-error.missing-version',
defaultMessage: 'Failed to download update: no version available',
},
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
defaultMessage: 'Version {version} was successfully installed!',
@@ -467,10 +527,6 @@ const messages = defineMessages({
id: 'app.nav.home',
defaultMessage: 'Home',
},
library: {
id: 'app.nav.library',
defaultMessage: 'Library',
},
modrinthHosting: {
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
@@ -530,6 +586,7 @@ function handleAdsConsentRequired(required) {
}
const notification = addPopupNotification({
contentType: 'standard',
title: formatMessage(messages.adsConsentTitle),
text: formatMessage(messages.adsConsentBody),
type: 'info',
@@ -562,6 +619,16 @@ function handleAdsConsentRequired(required) {
}
async function setupApp() {
await onboardingChecklist.initialize()
if (shouldShowNewIconEditorNotification(showChecklist.value)) {
addPopupNotification({
contentType: 'custom',
component: NewIconEditorNotification,
autoCloseMs: null,
})
}
const {
native_decorations,
theme,
@@ -570,8 +637,6 @@ async function setupApp() {
collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
onboarded,
default_page,
toggle_sidebar,
developer_mode,
feature_flags,
@@ -583,16 +648,10 @@ async function setupApp() {
i18n.global.locale.value = locale
}
if (default_page === 'Library') {
await router.push('/library')
}
os.value = await getOS()
const dev = await isDev()
isDevEnvironment.value = dev
const version = await getVersion()
showOnboarding.value = !onboarded
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
@@ -614,7 +673,7 @@ async function setupApp() {
if (telemetry) {
initAnalytics()
if (dev) debugAnalytics()
trackEvent('Launched', { version, dev, onboarded })
trackEvent('Launched', { version, dev })
}
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
@@ -818,6 +877,7 @@ const {
projectInfo: contentInstallProjectInfo,
handleInstallToInstance,
handleCreateAndInstall,
prepareNewInstance,
handleNavigate: handleContentInstallNavigate,
handleCancel: handleContentInstallCancel,
setContentInstallModal,
@@ -837,6 +897,35 @@ const {
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
} = contentInstall
async function prepareCreationProjectInstall(projectId, projectType) {
if (projectType === 'modpack') {
await contentInstall.install(
projectId,
null,
null,
'CreationModalProject',
undefined,
(instanceId) => void router.push(`/instance/${encodeURIComponent(instanceId)}`),
)
return null
}
await prepareNewInstance(projectId)
const info = contentInstallProjectInfo.value
if (!info) throw new Error(`Project information is unavailable: '${projectId}'`)
return {
projectId,
title: info.title,
iconUrl: info.iconUrl,
link: info.link,
owner: info.owner,
compatibleLoaders: [...contentInstallLoaders.value],
gameVersions: [...contentInstallGameVersions.value],
releaseGameVersions: new Set(contentInstallReleaseGameVersions.value),
}
}
const serverInstall = createServerInstall({
router,
handleError,
@@ -1090,17 +1179,16 @@ async function handleLiveNotification(notification) {
if (generation !== liveNotificationGeneration) return
const popupNotification = addPopupNotification({
contentType: 'toast',
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
autoCloseMs: null,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
serverInvitePopupNotificationIds.add(popupNotification.id)
}
@@ -1277,6 +1365,7 @@ function showDelayedUpdatePopup() {
if (metered.value && !finishedDownloading.value) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
type: 'info',
@@ -1298,6 +1387,7 @@ function showDelayedUpdatePopup() {
})
} else if (finishedDownloading.value) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.downloadComplete),
text: formatMessage(updatePopupMessages.downloadedBody, {
version: update.version,
@@ -1396,6 +1486,7 @@ async function checkLinuxUpdates() {
const nextPopupTime = getNextAppUpdatePopupTime(latestVersion)
if (nextPopupTime !== null && Date.now() >= nextPopupTime) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
type: 'info',
@@ -1415,7 +1506,7 @@ async function downloadAvailableUpdate() {
async function downloadUpdate(versionToDownload) {
if (!versionToDownload) {
handleError(`Failed to download update: no version available`)
handleError(formatMessage(messages.updateDownloadMissingVersion))
return
}
@@ -1579,18 +1670,34 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
type="instance"
show-snapshot-toggle
:fetch-existing-instance-names="fetchExistingInstanceNames"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
:search-projects="searchProjects"
:prepare-project-install="prepareCreationProjectInstall"
:create-project-install="handleCreateAndInstall"
:get-loader-manifest="getLoaderManifest"
:randomize-instance-icon="randomizeCreationIcon"
:customize-instance-icon="customizeCreationIcon"
@create="handleCreate"
@browse-modpacks="handleBrowseModpacks"
/>
<IconEditorModal
ref="creationIconEditorModal"
:config="creationGeneratedIcon?.config"
@saved="onCreationIconSaved"
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
>
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
<HomeIcon />
<NavButton
v-tooltip.right="formatMessage(messages.home)"
to="/"
:is-primary="(route) => route.path === '/'"
:is-subpage="
() =>
(route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
"
>
<PlayIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
@@ -1605,19 +1712,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.library)"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
() =>
route.path.startsWith('/instance') ||
((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
route.query.i)
"
>
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
@@ -1652,7 +1746,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
v-tooltip.right="formatMessage(messages.modrinthAccount)"
type="quiet"
size="xl"
label="More options"
:label="formatMessage(messages.moreOptions)"
:options="[
{
id: 'view-profile',
@@ -1707,7 +1801,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<IconButton
type="outlined"
label="Go back"
:label="formatMessage(messages.goBack)"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateBack"
@click="router.back()"
@@ -1719,7 +1813,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</IconButton>
<IconButton
type="outlined"
label="Go forward"
:label="formatMessage(messages.goForward)"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward"
@click="router.forward()"
@@ -1736,7 +1830,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<IconButton
v-if="!forceSidebar && themeStore.toggleSidebar"
:type="sidebarToggled ? 'base' : 'quiet'"
label="Next image"
:label="formatMessage(messages.nextImage)"
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
@@ -1808,7 +1902,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<RouterView v-else v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<KeepAlive include="LibraryPage">
<component :is="Component"></component>
</KeepAlive>
</Suspense>
</template>
</RouterView>
@@ -1823,9 +1919,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:class="{ 'pb-12': !hasPlus }"
data-overlayscrollbars-initialize
>
<OnboardingChecklist
@create-instance="installationModal?.show()"
@login-minecraft="accounts?.login()"
@login-modrinth="signIn"
/>
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<div
v-show="hasLoggedIntoMinecraft"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
</h3>
@@ -1833,7 +1937,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<AccountsCard ref="accounts" />
</suspense>
</div>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<div
v-show="showFriendsList"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
</suspense>
@@ -1897,6 +2004,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:preferred-game-version="contentInstallPreferredGameVersion"
:release-game-versions="contentInstallReleaseGameVersions"
:project-info="contentInstallProjectInfo"
:randomize-icon="randomizeCreationIcon"
:customize-icon="customizeContentInstallIcon"
@install="handleInstallToInstance"
@create-and-install="handleCreateAndInstall"
@navigate="handleContentInstallNavigate"
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="30.8333" height="20.8333" viewBox="0 0 30.8333 20.8333" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M1.6228 20.8333C2.51907 20.8333 3.24562 20.1158 3.24562 19.2308L3.24563 17.5253C3.24562 17.0803 3.2459 16.016 3.7136 15.2292C4.00182 14.7442 4.37085 14.339 4.74993 14.1326C5.36078 13.8001 6.0611 13.6111 6.80555 13.6111C7.05077 13.6111 7.3144 13.6219 7.6104 13.6356L7.7201 13.6408C7.97828 13.6529 8.25927 13.6661 8.5395 13.6719C9.17298 13.6851 9.91222 13.6651 10.6258 13.4739C12.0158 13.1014 13.1015 12.0158 13.4738 10.6258C13.665 9.91222 13.685 9.17298 13.672 8.5395C13.6662 8.25937 13.6528 7.9782 13.6408 7.7201L13.6357 7.6104C13.6218 7.3144 13.6112 7.05077 13.6112 6.80555C13.6112 6.02607 13.8182 5.29497 14.1805 4.66428C14.3917 4.29663 14.7595 3.96015 15.2675 3.66463C16.0377 3.21645 16.96 3.2123 17.5237 3.20975C18.0872 3.20722 18.6818 3.20527 19.1787 3.20527C22.2357 3.20527 23.347 3.2282 24.1857 3.5193C25.6395 4.02402 26.7495 5.07765 27.2667 6.36838C27.3963 6.69185 27.4878 7.1167 27.5368 7.85255C27.5868 8.60265 27.5877 9.56012 27.5877 10.9399V19.2308C27.5877 20.1158 28.3143 20.8333 29.2105 20.8333C30.1068 20.8333 30.8333 20.1158 30.8333 19.2308V10.8854C30.8333 9.5729 30.8333 8.50977 30.7755 7.6419C30.7158 6.74778 30.5898 5.95072 30.2847 5.18902C29.4037 2.99072 27.5568 1.29222 25.262 0.495583C23.8303 -0.0014166 22.1092 -0.000816752 19.5178 8.32478e-05L17.939 0.000200073L17.9167 0L17.8582 0.000249942C13.6775 0.00308328 11.0862 0.0528001 8.93633 0.79915C5.23583 2.08378 2.2801 4.81432 0.87485 8.32102C0.40655 9.48963 0.199033 10.7449 0.0986334 12.2503C-1.66334e-05 13.7293 0 15.5563 0 17.8857V19.2308C0 20.1158 0.72655 20.8333 1.6228 20.8333Z" fill="#B0BAC5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

@@ -1,360 +0,0 @@
<script setup>
import {
ClipboardCopyIcon,
EyeIcon,
FolderOpenIcon,
PlayIcon,
PlusIcon,
SearchIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import {
Accordion,
DropdownSelect,
formatLoader,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { install_duplicate_instance } from '@/helpers/install'
import { remove } from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps({
instances: {
type: Array,
default() {
return []
},
},
label: {
type: String,
default: '',
},
})
const instanceOptions = ref(null)
const instanceComponents = ref(null)
const currentDeleteInstance = ref(null)
const confirmModal = ref(null)
async function deleteInstance() {
if (currentDeleteInstance.value) {
instanceComponents.value = instanceComponents.value.filter(
(x) => x.instance.id !== currentDeleteInstance.value,
)
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateInstance(p) {
await install_duplicate_instance(p).catch(handleError)
}
const handleRightClick = (event, instanceId) => {
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
const baseOptions = [
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{ type: 'divider' },
{
name: 'delete',
color: 'danger',
},
]
instanceOptions.value.showMenu(
event,
item,
item.playing
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
...(item.instance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
args.item.play(null, 'InstanceGridContextMenu')
break
case 'stop':
args.item.stop(null, 'InstanceGridContextMenu')
break
case 'add_content':
await args.item.addContent()
break
case 'edit':
await args.item.seeInstance()
break
case 'duplicate':
if (args.item.instance.install_stage == 'installed')
await duplicateInstance(args.item.instance.id)
break
case 'open':
await args.item.openFolder()
break
case 'copy':
await navigator.clipboard.writeText(args.item.instance.id)
break
case 'delete':
currentDeleteInstance.value = args.item.instance.id
confirmModal.value.show()
break
}
}
const state = useStorage(
`${props.label}-grid-display-state`,
{
group: 'Group',
sortBy: 'Name',
collapsedGroups: [],
},
localStorage,
{ mergeDefaults: true },
)
const search = ref('')
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
const getSectionKey = (sectionName) => `${state.value.group}:${sectionName}`
const isSectionCollapsed = (sectionName) => {
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
}
const setSectionCollapsed = (sectionName, collapsed) => {
const sectionKey = getSectionKey(sectionName)
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
if (collapsed) {
collapsedSections.add(sectionKey)
} else {
collapsedSections.delete(sectionKey)
}
state.value.collapsedGroups = [...collapsedSections]
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
const instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase())
})
if (sortBy === 'Name') {
instances.sort((a, b) => {
return a.name.localeCompare(b.name)
})
}
if (sortBy === 'Game version') {
instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
})
}
if (sortBy === 'Last played') {
instances.sort((a, b) => {
return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))
})
}
if (sortBy === 'Date created') {
instances.sort((a, b) => {
return dayjs(b.date_created).diff(dayjs(a.date_created))
})
}
if (sortBy === 'Date modified') {
instances.sort((a, b) => {
return dayjs(b.date_modified).diff(dayjs(a.date_modified))
})
}
const instanceMap = new Map()
if (group === 'Loader') {
instances.forEach((instance) => {
const loader = formatLoader(formatMessage, instance.loader)
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
instanceMap.get(loader).push(instance)
})
} else if (group === 'Game version') {
instances.forEach((instance) => {
if (!instanceMap.has(instance.game_version)) {
instanceMap.set(instance.game_version, [])
}
instanceMap.get(instance.game_version).push(instance)
})
} else if (group === 'Group') {
instances.forEach((instance) => {
if (instance.groups.length === 0) {
instance.groups.push('None')
}
for (const category of instance.groups) {
if (!instanceMap.has(category)) {
instanceMap.set(category, [])
}
instanceMap.get(category).push(instance)
}
})
} else {
return instanceMap.set('None', instances)
}
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first
if (a[0] === 'None' && b[0] !== 'None') {
return -1
}
if (a[0] !== 'None' && b[0] === 'None') {
return 1
}
return a[0].localeCompare(b[0])
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})
</script>
<template>
<div class="flex gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
placeholder="Search"
clearable
wrapper-class="flex-1"
/>
<DropdownSelect
v-slot="{ selected }"
v-model="state.sortBy"
name="Sort Dropdown"
class="max-w-[16rem]"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="state.group"
class="max-w-[16rem]"
name="Group Dropdown"
:options="['Group', 'Loader', 'Game version', 'None']"
placeholder="Select..."
>
<span class="font-semibold text-primary">Group by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
</div>
<Accordion
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key,
value,
}))"
:key="instanceSection.key"
:divider="instanceSection.key !== 'None'"
:open-by-default="!isSectionCollapsed(instanceSection.key)"
class="row"
@on-open="setSectionCollapsed(instanceSection.key, false)"
@on-close="setSectionCollapsed(instanceSection.key, true)"
>
<template v-if="instanceSection.key !== 'None'" #title>
<span class="text-base">{{ instanceSection.key }}</span>
</template>
<section class="instances">
<Instance
v-for="instance in instanceSection.value"
ref="instanceComponents"
:key="instance.id + instance.install_stage"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
/>
</section>
</Accordion>
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteInstance" />
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #delete> <TrashIcon /> Delete </template>
<template #open> <FolderOpenIcon /> Open folder </template>
<template #copy> <ClipboardCopyIcon /> Copy path </template>
</ContextMenu>
</template>
<style lang="scss" scoped>
.row {
width: 100%;
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
width: 100%;
gap: 0.75rem;
margin-right: auto;
scroll-behavior: smooth;
overflow-y: auto;
}
</style>
@@ -1,371 +0,0 @@
<script setup>
import {
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { kill, remove, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process.js'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const router = useRouter()
const props = defineProps({
instances: {
type: Array,
default() {
return []
},
},
label: {
type: String,
default: '',
},
canPaginate: Boolean,
})
const actualInstances = computed(() =>
props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
),
)
const modsRow = ref(null)
const instanceOptions = ref(null)
const instanceComponents = ref(null)
const rows = ref(null)
const deleteConfirmModal = ref(null)
const currentDeleteInstance = ref(null)
async function deleteInstance() {
if (currentDeleteInstance.value) {
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateInstance(p) {
await install_duplicate_instance(p).catch(handleError)
}
const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [
...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open_folder' },
{ name: 'copy_path' },
{ type: 'divider' },
{
name: 'delete',
color: 'danger',
},
]
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
const options =
runningProcesses.length > 0
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
...(passedInstance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
]
instanceOptions.value.showMenu(event, passedInstance, options)
}
const handleProjectClick = (event, passedInstance) => {
instanceOptions.value.showMenu(event, passedInstance, [
{
name: 'install',
color: 'primary',
},
{ type: 'divider' },
{
name: 'open_link',
},
{
name: 'copy_link',
},
])
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
await run(args.item.id).catch((err) => handleSevereError(err, { instanceId: args.item.id }))
trackEvent('InstanceStart', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'stop':
await kill(args.item.id).catch(handleError)
trackEvent('InstanceStop', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'add_content':
await router.push({
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: args.item.id },
})
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(args.item.id)}`,
})
break
case 'duplicate':
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
break
case 'delete':
currentDeleteInstance.value = args.item.id
deleteConfirmModal.value.show()
break
case 'open_folder':
await showInstanceInFolder(args.item.id)
break
case 'copy_path':
await navigator.clipboard.writeText(args.item.id)
break
case 'install': {
await installVersion(
args.item.project_id,
null,
null,
'ProjectCardContextMenu',
() => {},
() => {},
).catch(handleError)
break
}
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
break
case 'copy_link':
await navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
)
break
}
}
const maxInstancesPerCompactRow = ref(1)
const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value.length === 0) {
return
}
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2
}
if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2
}
if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2
}
}
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value)
}
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value)
}
})
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteInstance" />
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">
{{ row.label }}
</HeadingLink>
<section
v-if="row.instance"
ref="modsRow"
class="instances"
:class="{ compact: row.compact }"
>
<Instance
v-for="(instance, instanceIndex) in row.instances.slice(
0,
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
)"
:key="row.label + instance.id"
:instance="instance"
:compact="row.compact"
:first="instanceIndex === 0"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/>
</section>
<section v-else ref="modsRow" class="projects">
<LegacyProjectCard
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
:key="project?.project_id"
ref="instanceComponents"
class="item"
:project="project"
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
/>
</section>
</div>
</div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template>
<template #delete> <TrashIcon /> Delete </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu>
</template>
<style lang="scss" scoped>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: 1rem;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
background: transparent;
}
}
.row {
display: flex;
flex-direction: column;
align-items: flex-start;
overflow: hidden;
width: 100%;
min-width: 100%;
&:nth-child(even) {
background: var(--color-bg);
}
.header {
width: 100%;
margin-bottom: 1rem;
gap: var(--gap-xs);
display: flex;
flex-direction: row;
align-items: center;
a {
margin: 0;
font-size: var(--font-size-md);
font-weight: bolder;
white-space: nowrap;
color: var(--color-base);
}
svg {
height: 1.25rem;
width: 1.25rem;
color: var(--color-base);
}
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-gap: 0.75rem;
width: 100%;
&.compact {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.75rem;
}
}
.projects {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 0.75rem;
.item {
width: 100%;
max-width: 100%;
}
}
}
</style>
@@ -185,6 +185,7 @@ defineExpose({
refreshValues,
setEquippedSkin,
setLoginDisabled,
login,
loginDisabled,
})
@@ -137,8 +137,8 @@ import {
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationProgressItem,
type PopupNotificationStandard,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -150,6 +150,7 @@ import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { toError } from '@/helpers/errors'
import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
@@ -352,14 +353,16 @@ function getDisplayIconUrl(icon: string | null | undefined): string | null {
return convertFileSrc(icon)
}
function getNotification(): PopupNotification | null {
function getNotification(): PopupNotificationStandard | null {
if (!notificationId.value) {
return null
}
const notification = popupNotificationManager
.getNotifications()
.find((notification) => notification.id === notificationId.value)
return notification ?? null
return notification?.contentType === 'standard' && notification.type === 'download'
? notification
: null
}
function removeNotification(): void {
@@ -377,11 +380,17 @@ function syncTerminalNotifications(): void {
for (const terminal of terminalNotifications) {
const popupId = terminalNotificationIds.get(terminal.id)
let notification = popupId
? popupNotificationManager.getNotifications().find((candidate) => candidate.id === popupId)
? popupNotificationManager
.getNotifications()
.find(
(candidate): candidate is PopupNotificationStandard =>
candidate.id === popupId && candidate.contentType === 'standard',
)
: undefined
if (!notification) {
notification = popupNotificationManager.addPopupNotification({
contentType: 'standard',
title: terminal.title,
text: terminal.text,
type: terminal.type,
@@ -411,7 +420,7 @@ function syncTerminalNotifications(): void {
function buildDownloadItems(): PopupNotificationProgressItem[] {
return [
...installJobNotifications.progressItems.value,
...currentLoadingBars.value.map((bar) => ({
...currentLoadingBars.value.map<PopupNotificationProgressItem>((bar) => ({
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
@@ -451,7 +460,7 @@ function updateNotification(resummon = false): void {
return
}
let notif = getNotification()
const notif = getNotification()
const progressItems = buildDownloadItems()
if (notif) {
@@ -463,7 +472,8 @@ function updateNotification(resummon = false): void {
notif.progress = undefined
notif.waiting = undefined
} else {
notif = popupNotificationManager.addPopupNotification({
const notification = popupNotificationManager.addPopupNotification({
contentType: 'standard',
title: installJobNotifications.active.value
? installJobNotifications.title.value
: formatMessage(messages.downloads),
@@ -471,7 +481,7 @@ function updateNotification(resummon = false): void {
autoCloseMs: null,
progressItems,
})
notificationId.value = notif.id
notificationId.value = notification.id
}
}
@@ -548,7 +558,7 @@ async function refreshLoadingBars() {
const installJobNotifications = await useInstallJobNotifications({
router,
handleError,
handleError: (error) => handleError(toError(error)),
onChange: updateNotification,
})
@@ -34,9 +34,18 @@ const options = ref([])
const left = ref('0px')
const top = ref('0px')
const shown = ref(false)
const contextMenuId = Symbol()
const contextMenuOpenEvent = 'modrinth-context-menu-open'
const hideContextMenu = () => {
shown.value = false
emit('menu-closed')
}
defineExpose({
showMenu: (event, passedItem, passedOptions) => {
window.dispatchEvent(new CustomEvent(contextMenuOpenEvent, { detail: contextMenuId }))
item.value = passedItem
options.value = passedOptions
@@ -62,6 +71,7 @@ defineExpose({
}
})
},
hideMenu: hideContextMenu,
})
const isInstanceLink = (item) => {
@@ -73,11 +83,6 @@ const isInstanceLink = (item) => {
return false
}
const hideContextMenu = () => {
shown.value = false
emit('menu-closed')
}
const optionClicked = (option) => {
emit('option-clicked', {
item: item.value,
@@ -92,6 +97,12 @@ const onEscKeyRelease = (event) => {
}
}
const handleContextMenuOpen = (event) => {
if (shown.value && event.detail !== contextMenuId) {
hideContextMenu()
}
}
const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
@@ -105,11 +116,13 @@ const handleClickOutside = (event) => {
onMounted(() => {
window.addEventListener('click', handleClickOutside)
window.addEventListener(contextMenuOpenEvent, handleContextMenuOpen)
document.body.addEventListener('keyup', onEscKeyRelease)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
window.removeEventListener(contextMenuOpenEvent, handleContextMenuOpen)
document.removeEventListener('keyup', onEscKeyRelease)
})
</script>
@@ -128,15 +141,24 @@ onBeforeUnmount(() => {
.item {
align-items: center;
color: var(--color-base);
color: var(--color-text-primary);
font-weight: 500;
cursor: pointer;
display: flex;
gap: var(--gap-sm);
padding: var(--gap-sm);
border-radius: var(--radius-sm);
:deep(svg) {
color: var(--color-base);
}
&:hover,
&:active {
:deep(svg) {
color: inherit;
}
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
@@ -145,19 +167,19 @@ onBeforeUnmount(() => {
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: bold;
font-weight: 500;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: bold;
font-weight: 500;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: bold;
font-weight: 500;
}
}
}
@@ -7,8 +7,14 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import {
Avatar,
defineMessages,
IconButton,
injectNotificationManager,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -16,13 +22,24 @@ import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const messages = defineMessages({
instanceIcon: { id: 'app.instance.card.icon-alt', defaultMessage: 'Instance icon' },
stop: { id: 'app.instance.card.stop', defaultMessage: 'Stop' },
loading: { id: 'app.instance.card.loading', defaultMessage: 'Instance is loading...' },
installing: { id: 'app.instance.card.installing', defaultMessage: 'Installing...' },
play: { id: 'app.instance.card.play', defaultMessage: 'Play' },
repair: { id: 'app.instance.card.repair', defaultMessage: 'Repair' },
played: { id: 'app.instance.card.played', defaultMessage: 'Played {relativeTime}' },
neverPlayed: { id: 'app.instance.card.never-played', defaultMessage: 'Never played' },
})
const props = defineProps({
instance: {
@@ -159,9 +176,9 @@ onMounted(() => {
>
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
alt="Mod card"
:alt="formatMessage(messages.instanceIcon)"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
@@ -169,10 +186,10 @@ onMounted(() => {
<div class="flex items-center">
<IconButton
v-if="playing"
v-tooltip="'Stop'"
v-tooltip="formatMessage(messages.stop)"
type="colored"
color="red"
:label="'Stop'"
:label="formatMessage(messages.stop)"
@mouseenter="checkProcess"
@click="(e) => stop(e, 'InstanceCard')"
>
@@ -180,18 +197,18 @@ onMounted(() => {
</IconButton>
<IconButton
v-else-if="modLoading"
v-tooltip="'Instance is loading...'"
:label="'Instance is loading...'"
v-tooltip="formatMessage(messages.loading)"
:label="formatMessage(messages.loading)"
disabled
>
<SpinnerIcon class="animate-spin" />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
v-tooltip="formatMessage(messages.play)"
:type="first ? 'colored' : 'base'"
:color="first ? 'brand' : undefined"
label="Play"
:label="formatMessage(messages.play)"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
@@ -203,9 +220,13 @@ onMounted(() => {
<TimerIcon />
<span class="text-sm">
<template v-if="instance.last_played">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
{{
formatMessage(messages.played, {
relativeTime: formatRelativeTime(dayjs(instance.last_played).toISOString()),
})
}}
</template>
<template v-else> Never played </template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
</span>
</div>
</div>
@@ -219,19 +240,19 @@ onMounted(() => {
<div class="relative flex items-center justify-center">
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
alt="Mod card"
:alt="formatMessage(messages.instanceIcon)"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<IconButton
v-if="playing"
v-tooltip="'Stop'"
v-tooltip="formatMessage(messages.stop)"
type="colored"
color="red"
size="xl"
:label="'Stop'"
:label="formatMessage(messages.stop)"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@@ -241,17 +262,17 @@ onMounted(() => {
</IconButton>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<IconButton
v-else-if="!installed && !instance.quarantined"
v-tooltip="'Repair'"
v-tooltip="formatMessage(messages.repair)"
type="colored"
color="brand"
size="xl"
:label="'Repair'"
:label="formatMessage(messages.repair)"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
@@ -259,11 +280,11 @@ onMounted(() => {
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
v-tooltip="formatMessage(messages.play)"
type="colored"
color="brand"
size="xl"
:label="'Play'"
:label="formatMessage(messages.play)"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
@@ -1,9 +1,18 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Avatar, ButtonLink, defineMessages, FormattedTag, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { getInstanceIconUrl } from '@/helpers/instance'
const { formatMessage } = useVIntl()
const messages = defineMessages({
backToInstance: {
id: 'app.instance.navigation.back-to-instance',
defaultMessage: 'Back to instance',
},
})
type Instance = {
game_version: string
loader: string
@@ -31,11 +40,7 @@ const instanceLink = computed(() => {
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
<span class="flex items-center gap-2">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:alt="instance.name"
size="48px"
/>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" :alt="instance.name" size="48px" />
<span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast">
{{ instance.name }}
@@ -48,7 +53,9 @@ const instanceLink = computed(() => {
</span>
</span>
</router-link>
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
<ButtonLink :to="instanceLink">
<LeftArrowIcon /> {{ formatMessage(messages.backToInstance) }}
</ButtonLink>
</div>
</template>
@@ -2,13 +2,12 @@
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { list } from '@/helpers/instance'
import { getInstanceIconUrl, list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
@@ -201,7 +200,7 @@ const dividerTooltip = computed(() => {
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:src="getInstanceIconUrl(instance.icon_path)"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ImportIcon, PlusIcon } from '@modrinth/assets'
import { Button, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
import { inject, onMounted, onUnmounted, ref } from 'vue'
import modrinthSocialIcon from '../../assets/welcome/modrinth-social-icon.png'
const showCreationModal = inject<() => void>('showCreationModal')
const showImportModal = inject<() => void>('showImportModal')
const { formatMessage } = useVIntl()
const messages = defineMessages({
welcomeTitle: {
id: 'app.welcome-screen.title',
defaultMessage: 'Welcome to Modrinth',
},
welcomeDescription: {
id: 'app.welcome-screen.description',
defaultMessage: 'Ready to start playing?',
},
createInstance: {
id: 'app.welcome-screen.create-instance',
defaultMessage: 'Create an instance',
},
quickCreateHint: {
id: 'app.welcome-screen.quick-create-hint',
defaultMessage: 'Press <shortcut>N</shortcut> to quick create an instance',
},
importPrompt: {
id: 'app.welcome-screen.import-prompt',
defaultMessage: 'Escaping another launcher?',
},
importFromLauncher: {
id: 'app.welcome-screen.import-from-launcher',
defaultMessage: 'Import from launcher',
},
})
const offline = ref(!navigator.onLine)
function handleOffline() {
offline.value = true
}
function handleOnline() {
offline.value = false
}
function handleQuickCreate(event: KeyboardEvent) {
const target = event.target as HTMLElement | null
if (
event.key.toLowerCase() !== 'n' ||
event.repeat ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
target?.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')
) {
return
}
if (!offline.value) {
event.preventDefault()
showCreationModal?.()
}
}
onMounted(() => {
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
window.addEventListener('keydown', handleQuickCreate)
})
onUnmounted(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
window.removeEventListener('keydown', handleQuickCreate)
})
</script>
<template>
<div class="flex flex-col min-h-full px-6 pb-6 pt-16">
<div class="relative flex grow items-center justify-center">
<div class="relative isolate flex flex-col items-center gap-6">
<div
class="dot-pattern pointer-events-none absolute left-1/2 -top-52 -z-10 h-[29.875rem] w-[min(25.9375rem,80vw)] -translate-x-1/2 rounded-2xl [@media(max-height:700px)]:h-[23rem]"
aria-hidden="true"
/>
<div class="size-[6.25rem]">
<img :src="modrinthSocialIcon" alt="" class="pointer-events-none size-full" />
</div>
<div class="flex flex-col items-center gap-2">
<h1 class="m-0 flex items-center gap-2 text-2xl font-semibold leading-8 text-contrast">
{{ formatMessage(messages.welcomeTitle) }}
</h1>
<p class="m-0 text-center text-base leading-6 text-primary">
{{ formatMessage(messages.welcomeDescription) }}
</p>
</div>
<div class="flex w-72 flex-col items-center gap-4">
<Button
type="colored"
color="brand"
size="lg"
class="!shadow-none"
:disabled="offline"
@click="showCreationModal?.()"
>
<PlusIcon />
{{ formatMessage(messages.createInstance) }}
</Button>
<span class="flex items-center gap-1 text-sm leading-5 text-secondary">
<IntlFormatted :message-id="messages.quickCreateHint">
<template #shortcut="{ children }">
<kbd
class="inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-solid border-surface-5 bg-button-bg px-1 text-xs font-normal leading-4 text-primary"
>
<component :is="() => children" />
</kbd>
</template>
</IntlFormatted>
</span>
</div>
</div>
</div>
<div
class="flex flex-col h-max items-center justify-end gap-4 text-sm leading-5 text-secondary"
>
<span class="whitespace-nowrap">{{ formatMessage(messages.importPrompt) }}</span>
<Button size="lg" class="!font-medium" :disabled="offline" @click="showImportModal?.()">
<ImportIcon />
{{ formatMessage(messages.importFromLauncher) }}
</Button>
</div>
</div>
</template>
<style scoped>
.dot-pattern {
background-image: radial-gradient(
circle,
color-mix(in srgb, var(--color-text-primary) 25%, transparent) 0.5px,
transparent 0.75px
);
background-size: 0.5625rem 0.5625rem;
opacity: 0.8;
-webkit-mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
</style>
@@ -1,17 +1,40 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
import {
Admonition,
Avatar,
Button,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance'
import { getInstanceIconUrl, list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.instance.add-server.title', defaultMessage: 'Add server to instance' },
compatibilityWarning: {
id: 'app.instance.add-server.compatibility-warning',
defaultMessage: 'This server may not be compatible with all instances.',
},
searchPlaceholder: {
id: 'app.instance.add-server.search-placeholder',
defaultMessage: 'Search for an instance',
},
adding: { id: 'app.instance.add-server.adding', defaultMessage: 'Adding...' },
added: { id: 'app.instance.add-server.added', defaultMessage: 'Added' },
add: { id: 'app.instance.add-server.add', defaultMessage: 'Add' },
cancel: { id: 'app.instance.add-server.cancel', defaultMessage: 'Cancel' },
})
const queryClient = useQueryClient()
const modal = ref()
@@ -77,14 +100,14 @@ async function addServer(instance) {
</script>
<template>
<ModalWrapper ref="modal" header="Add server to instance">
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" body="This server may not be compatible with all instances." />
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
placeholder="Search for an instance"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
@@ -98,21 +121,22 @@ async function addServer(instance) {
:to="`/instance/${encodeURIComponent(instance.id)}`"
@click="modal.hide()"
>
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
class="mr-2 [--size:2rem]"
/>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" class="mr-2 [--size:2rem]" />
{{ instance.name }}
</router-link>
<Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
<PlusIcon v-if="!instance.added && !instance.adding" />
<CheckIcon v-else-if="instance.added" />
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
{{
formatMessage(
instance.adding ? messages.adding : instance.added ? messages.added : messages.add,
)
}}
</Button>
</div>
</div>
<div class="input-group push-right">
<Button @click="modal.hide()">Cancel</Button>
<Button @click="modal.hide()">{{ formatMessage(messages.cancel) }}</Button>
</div>
</div>
</ModalWrapper>
@@ -0,0 +1,466 @@
import { defineMessages } from '@modrinth/ui'
import backpack from '@/assets/instance-icons/backpack.png'
import beacon from '@/assets/instance-icons/beacon.png'
import blueShark from '@/assets/instance-icons/blue-shark.png'
import bookshelf from '@/assets/instance-icons/bookshelf.png'
import brownBear from '@/assets/instance-icons/brown-bear.png'
import cake from '@/assets/instance-icons/cake.png'
import campfire from '@/assets/instance-icons/campfire.png'
import chest from '@/assets/instance-icons/chest.png'
import cogwheel from '@/assets/instance-icons/cogwheel.png'
import commandBlock from '@/assets/instance-icons/command-block.png'
import cookingPot from '@/assets/instance-icons/cooking-pot.png'
import couch from '@/assets/instance-icons/couch.png'
import craftingTable from '@/assets/instance-icons/crafting-table.png'
import creeper from '@/assets/instance-icons/creeper.png'
import enchantingTable from '@/assets/instance-icons/enchanting-table.png'
import enderChest from '@/assets/instance-icons/ender-chest.png'
import enderDragon from '@/assets/instance-icons/ender-dragon.png'
import engine from '@/assets/instance-icons/engine.png'
import furnace from '@/assets/instance-icons/furnace.png'
import gizmo from '@/assets/instance-icons/gizmo.png'
import globe from '@/assets/instance-icons/globe.png'
import grassBlock from '@/assets/instance-icons/grass-block.png'
import lantern from '@/assets/instance-icons/lantern.png'
import moobloom from '@/assets/instance-icons/moobloom.png'
import mrPack from '@/assets/instance-icons/mr-pack.png'
import orb from '@/assets/instance-icons/orb.png'
import oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
import pancakes from '@/assets/instance-icons/pancakes.png'
import pickaxe from '@/assets/instance-icons/pickaxe.png'
import pokeBall from '@/assets/instance-icons/poke-ball.png'
import redstoneBlock from '@/assets/instance-icons/redstone-block.png'
import sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
import skeleton from '@/assets/instance-icons/skeleton.png'
import skillet from '@/assets/instance-icons/skillet.png'
import slimeBlock from '@/assets/instance-icons/slime-block.png'
import spaceHelmet from '@/assets/instance-icons/space-helmet.png'
import stickyPiston from '@/assets/instance-icons/sticky-piston.png'
import sword from '@/assets/instance-icons/sword.png'
import terminal from '@/assets/instance-icons/terminal.png'
import tinyPotato from '@/assets/instance-icons/tiny-potato.png'
import tire from '@/assets/instance-icons/tire.png'
import tnt from '@/assets/instance-icons/tnt.png'
import wrench from '@/assets/instance-icons/wrench.png'
import wrenchRinth from '@/assets/instance-icons/wrench-rinth.png'
import zombie from '@/assets/instance-icons/zombie.png'
const names = defineMessages({
yellow: {
id: 'instance.icon-editor.background.yellow',
defaultMessage: 'Yellow',
},
green: {
id: 'instance.icon-editor.background.green',
defaultMessage: 'Green',
},
lime: {
id: 'instance.icon-editor.background.lime',
defaultMessage: 'Lime',
},
darkGreen: {
id: 'instance.icon-editor.background.dark-green',
defaultMessage: 'Dark green',
},
purple: {
id: 'instance.icon-editor.background.purple',
defaultMessage: 'Purple',
},
blue: {
id: 'instance.icon-editor.background.blue',
defaultMessage: 'Blue',
},
orange: {
id: 'instance.icon-editor.background.orange',
defaultMessage: 'Orange',
},
red: {
id: 'instance.icon-editor.background.red',
defaultMessage: 'Red',
},
rose: {
id: 'instance.icon-editor.background.rose',
defaultMessage: 'Rose',
},
pink: {
id: 'instance.icon-editor.background.pink',
defaultMessage: 'Pink',
},
indigo: {
id: 'instance.icon-editor.background.indigo',
defaultMessage: 'Indigo',
},
lavender: {
id: 'instance.icon-editor.background.lavender',
defaultMessage: 'Lavender',
},
lightGray: {
id: 'instance.icon-editor.background.light-gray',
defaultMessage: 'Light gray',
},
gray: {
id: 'instance.icon-editor.background.gray',
defaultMessage: 'Gray',
},
darkGray: {
id: 'instance.icon-editor.background.dark-gray',
defaultMessage: 'Dark gray',
},
backpack: { id: 'instance.icon-editor.symbol.backpack', defaultMessage: 'Backpack' },
beacon: { id: 'instance.icon-editor.symbol.beacon', defaultMessage: 'Beacon' },
blueShark: { id: 'instance.icon-editor.symbol.blue-shark', defaultMessage: 'Blue Shark' },
bookshelf: { id: 'instance.icon-editor.symbol.bookshelf', defaultMessage: 'Bookshelf' },
brownBear: { id: 'instance.icon-editor.symbol.brown-bear', defaultMessage: 'Brown Bear' },
cake: { id: 'instance.icon-editor.symbol.cake', defaultMessage: 'Cake' },
campfire: { id: 'instance.icon-editor.symbol.campfire', defaultMessage: 'Campfire' },
chest: { id: 'instance.icon-editor.symbol.chest', defaultMessage: 'Chest' },
cogwheel: { id: 'instance.icon-editor.symbol.cogwheel', defaultMessage: 'Cogwheel' },
commandBlock: {
id: 'instance.icon-editor.symbol.command-block',
defaultMessage: 'Command Block',
},
cookingPot: {
id: 'instance.icon-editor.symbol.cooking-pot',
defaultMessage: 'Cooking Pot',
},
couch: { id: 'instance.icon-editor.symbol.couch', defaultMessage: 'Couch' },
craftingTable: {
id: 'instance.icon-editor.symbol.crafting-table',
defaultMessage: 'Crafting Table',
},
creeper: { id: 'instance.icon-editor.symbol.creeper', defaultMessage: 'Creeper' },
enchantingTable: {
id: 'instance.icon-editor.symbol.enchanting-table',
defaultMessage: 'Enchanting Table',
},
enderChest: {
id: 'instance.icon-editor.symbol.ender-chest',
defaultMessage: 'Ender Chest',
},
enderDragon: {
id: 'instance.icon-editor.symbol.ender-dragon',
defaultMessage: 'Ender Dragon',
},
engine: { id: 'instance.icon-editor.symbol.engine', defaultMessage: 'Engine' },
furnace: { id: 'instance.icon-editor.symbol.furnace', defaultMessage: 'Furnace' },
gizmo: { id: 'instance.icon-editor.symbol.gizmo', defaultMessage: 'Gizmo' },
globe: { id: 'instance.icon-editor.symbol.globe', defaultMessage: 'Globe' },
grassBlock: {
id: 'instance.icon-editor.symbol.grass-block',
defaultMessage: 'Grass Block',
},
lantern: { id: 'instance.icon-editor.symbol.lantern', defaultMessage: 'Lantern' },
moobloom: { id: 'instance.icon-editor.symbol.moobloom', defaultMessage: 'Moobloom' },
mrPack: { id: 'instance.icon-editor.symbol.mr-pack', defaultMessage: 'Mr Pack' },
orb: { id: 'instance.icon-editor.symbol.orb', defaultMessage: 'Orb' },
oxygenDistributor: {
id: 'instance.icon-editor.symbol.oxygen-distributor',
defaultMessage: 'Oxygen Distributor',
},
pancakes: { id: 'instance.icon-editor.symbol.pancakes', defaultMessage: 'Pancakes' },
pickaxe: { id: 'instance.icon-editor.symbol.pickaxe', defaultMessage: 'Pickaxe' },
pokeBall: { id: 'instance.icon-editor.symbol.poke-ball', defaultMessage: 'Poke Ball' },
redstoneBlock: {
id: 'instance.icon-editor.symbol.redstone-block',
defaultMessage: 'Redstone Block',
},
sculkSensor: {
id: 'instance.icon-editor.symbol.sculk-sensor',
defaultMessage: 'Sculk Sensor',
},
skeleton: { id: 'instance.icon-editor.symbol.skeleton', defaultMessage: 'Skeleton' },
skillet: { id: 'instance.icon-editor.symbol.skillet', defaultMessage: 'Skillet' },
slimeBlock: {
id: 'instance.icon-editor.symbol.slime-block',
defaultMessage: 'Slime Block',
},
spaceHelmet: {
id: 'instance.icon-editor.symbol.space-helmet',
defaultMessage: 'Space Helmet',
},
stickyPiston: {
id: 'instance.icon-editor.symbol.sticky-piston',
defaultMessage: 'Sticky Piston',
},
sword: { id: 'instance.icon-editor.symbol.sword', defaultMessage: 'Sword' },
tnt: { id: 'instance.icon-editor.symbol.tnt', defaultMessage: 'TNT' },
terminal: { id: 'instance.icon-editor.symbol.terminal', defaultMessage: 'Terminal' },
tinyPotato: {
id: 'instance.icon-editor.symbol.tiny-potato',
defaultMessage: 'Tiny Potato',
},
tire: { id: 'instance.icon-editor.symbol.tire', defaultMessage: 'Tire' },
wrench: { id: 'instance.icon-editor.symbol.create-wrench', defaultMessage: 'Wrench' },
wrenchRinth: {
id: 'instance.icon-editor.symbol.wrenth-rinth',
defaultMessage: 'Modrinth Wrench',
},
zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' },
})
export const backgroundOptions = [
{
id: 'rose',
background: {
type: 'linear-top-down-gradient',
top_color: '#D62E63',
bottom_color: '#F95C62',
},
name: names.rose,
},
{
id: 'orange',
background: {
type: 'linear-top-down-gradient',
top_color: '#FF8D29',
bottom_color: '#FFB452',
},
name: names.orange,
},
{
id: 'yellow',
background: {
type: 'linear-top-down-gradient',
top_color: '#FFC629',
bottom_color: '#FFEE53',
},
name: names.yellow,
},
{
id: 'lime',
background: {
type: 'linear-top-down-gradient',
top_color: '#6FDA1D',
bottom_color: '#CBFF50',
},
name: names.lime,
},
{
id: 'green',
background: {
type: 'linear-top-down-gradient',
top_color: '#0B9F21',
bottom_color: '#4FD24B',
},
name: names.green,
},
// {
// id: 'dark_green',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#084C13',
// bottom_color: '#327735',
// },
// name: names.darkGreen,
// },
// {
// id: 'indigo',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#3F00D3',
// bottom_color: '#2659FE',
// },
// name: names.indigo,
// },
{
id: 'purple',
background: {
type: 'linear-top-down-gradient',
top_color: '#4739FF',
bottom_color: '#6670FF',
},
name: names.purple,
},
{
id: 'blue',
background: {
type: 'linear-top-down-gradient',
top_color: '#227EFF',
bottom_color: '#5EC1FF',
},
name: names.blue,
},
{
id: 'lavender',
background: {
type: 'linear-top-down-gradient',
top_color: '#C056FD',
bottom_color: '#B889FF',
},
name: names.lavender,
},
{
id: 'pink',
background: {
type: 'linear-top-down-gradient',
top_color: '#F640C0',
bottom_color: '#FF7BF1',
},
name: names.pink,
},
// {
// id: 'red',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#F6111C',
// bottom_color: '#F94548',
// },
// name: names.red,
// },
{
id: 'light_gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#AEAEAE',
bottom_color: '#D9D9D9',
},
name: names.lightGray,
},
{
id: 'gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#373C4C',
bottom_color: '#4C4F58',
},
name: names.gray,
},
{
id: 'dark_gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#1B1D29',
bottom_color: '#252731',
},
name: names.darkGray,
},
] as const
export const symbolOptions = [
// Cobblemon: Poké Ball
{ id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' },
// Origins: Orb of Origins
{ id: 'orb', name: names.orb, asset: orb, category: 'modded' },
// Farmer's Delight: Cooking Pot, Skillet
{ id: 'cooking_pot', name: names.cookingPot, asset: cookingPot, category: 'modded' },
{ id: 'skillet', name: names.skillet, asset: skillet, category: 'modded' },
// Supplementaries: Globe, Pancakes
{ id: 'globe', name: names.globe, asset: globe, category: 'modded' },
{ id: 'pancakes', name: names.pancakes, asset: pancakes, category: 'modded' },
// Sophisticated Backpacks: Backpack
{ id: 'backpack', name: names.backpack, asset: backpack, category: 'modded' },
// Chipped: Chair
{ id: 'couch', name: names.couch, asset: couch, category: 'modded' },
// Botania: Tiny Potato
{ id: 'tiny_potato', name: names.tinyPotato, asset: tinyPotato, category: 'modded' },
// Blåhaj: Blue Shark
{ id: 'blue_shark', name: names.blueShark, asset: blueShark, category: 'modded' },
// Other modded symbols: Brown Bear, Moobloom
{ id: 'brown_bear', name: names.brownBear, asset: brownBear, category: 'modded' },
{ id: 'moobloom', name: names.moobloom, asset: moobloom, category: 'modded' },
// Create: Wrench, Cogwheel
{ id: 'create_wrench', name: names.wrench, asset: wrench, category: 'modded' },
{ id: 'cogwheel', name: names.cogwheel, asset: cogwheel, category: 'modded' },
// Create Aeronautics: Engine, Tire
{ id: 'engine', name: names.engine, asset: engine, category: 'modded' },
{ id: 'tire', name: names.tire, asset: tire, category: 'modded' },
// Ad Astra: Oxygen Distributor, Space Helmet
{
id: 'oxygen_distributor',
name: names.oxygenDistributor,
asset: oxygenDistributor,
category: 'modded',
},
{ id: 'space_helmet', name: names.spaceHelmet, asset: spaceHelmet, category: 'modded' },
// Miscellaneous: Gizmo, Terminal
{ id: 'gizmo', name: names.gizmo, asset: gizmo, category: 'modded' },
{ id: 'terminal', name: names.terminal, asset: terminal, category: 'modded' },
// Miscellaneous: Modrinth Wrench, Mr Pack
{ id: 'wrenth_rinth', name: names.wrenchRinth, asset: wrenchRinth, category: 'modded' },
{ id: 'mr_pack', name: names.mrPack, asset: mrPack, category: 'modded' },
/////////////////////////
// vanilla ones
/////////////////////////
{ id: 'grass_block', name: names.grassBlock, asset: grassBlock, category: 'vanilla' },
{ id: 'crafting_table', name: names.craftingTable, asset: craftingTable, category: 'vanilla' },
{ id: 'furnace', name: names.furnace, asset: furnace, category: 'vanilla' },
{ id: 'chest', name: names.chest, asset: chest, category: 'vanilla' },
{ id: 'bookshelf', name: names.bookshelf, asset: bookshelf, category: 'vanilla' },
{
id: 'redstone_block',
name: names.redstoneBlock,
asset: redstoneBlock,
category: 'vanilla',
},
{
id: 'sticky_piston',
name: names.stickyPiston,
asset: stickyPiston,
category: 'vanilla',
},
{ id: 'slime_block', name: names.slimeBlock, asset: slimeBlock, category: 'vanilla' },
{ id: 'cake', name: names.cake, asset: cake, category: 'vanilla' },
{ id: 'campfire', name: names.campfire, asset: campfire, category: 'vanilla' },
{ id: 'pickaxe', name: names.pickaxe, asset: pickaxe, category: 'vanilla' },
{ id: 'sword', name: names.sword, asset: sword, category: 'vanilla' },
{ id: 'zombie', name: names.zombie, asset: zombie, category: 'vanilla' },
{ id: 'creeper', name: names.creeper, asset: creeper, category: 'vanilla' },
{ id: 'skeleton', name: names.skeleton, asset: skeleton, category: 'vanilla' },
{ id: 'ender_dragon', name: names.enderDragon, asset: enderDragon, category: 'vanilla' },
{ id: 'ender_chest', name: names.enderChest, asset: enderChest, category: 'vanilla' },
{ id: 'sculk_sensor', name: names.sculkSensor, asset: sculkSensor, category: 'vanilla' },
{ id: 'beacon', name: names.beacon, asset: beacon, category: 'vanilla' },
{
id: 'enchanting_table',
name: names.enchantingTable,
asset: enchantingTable,
category: 'vanilla',
},
{ id: 'lantern', name: names.lantern, asset: lantern, category: 'vanilla' },
{ id: 'tnt', name: names.tnt, asset: tnt, category: 'vanilla' },
{ id: 'command_block', name: names.commandBlock, asset: commandBlock, category: 'vanilla' },
] as const
export type BackgroundId = (typeof backgroundOptions)[number]['id']
export type SymbolId = (typeof symbolOptions)[number]['id']
export const RANDOM_CONFIG_BLACKLIST = [
{ background: 'purple', symbol: 'globe' },
{ background: 'blue', symbol: 'globe' },
{ background: 'gray', symbol: 'cogwheel' },
{ background: 'dark_gray', symbol: 'cogwheel' },
{ background: 'rose', symbol: 'poke_ball' },
{ background: 'lime', symbol: 'slime_block' },
{ background: 'green', symbol: 'slime_block' },
{ background: 'rose', symbol: 'redstone_block' },
{ background: 'rose', symbol: 'couch' },
{ background: 'orange', symbol: 'space_helmet' },
{ background: 'rose', symbol: 'tnt' },
{ background: 'yellow', symbol: 'moobloom' },
{ background: 'green', symbol: 'wrenth_rinth' },
{ background: 'lime', symbol: 'mr_pack' },
{ background: 'light_gray', symbol: 'skillet' },
{ background: 'light_gray', symbol: 'cooking_pot' },
] satisfies readonly { background: BackgroundId; symbol: SymbolId }[]
export const DEFAULT_BACKGROUND_ID = 'purple' satisfies BackgroundId
export const DEFAULT_SYMBOL_ID = 'grass_block' satisfies SymbolId
@@ -0,0 +1,537 @@
<script setup lang="ts">
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { toError } from '@/helpers/errors'
import {
cache_generated_icon,
edit_generated_icon,
edit_generated_icon_if_empty,
get_recent_icon_configs,
} from '@/helpers/instance'
import type { IconBackground, InstanceIconConfig } from '@/helpers/types'
import {
type BackgroundId,
backgroundOptions,
DEFAULT_BACKGROUND_ID,
DEFAULT_SYMBOL_ID,
RANDOM_CONFIG_BLACKLIST,
type SymbolId,
symbolOptions,
} from './editor-catalog'
const props = defineProps<{
instanceId?: string
config?: InstanceIconConfig | null
}>()
const emit = defineEmits<{
saved: [iconPath: string, config: InstanceIconConfig]
}>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const recentConfigs = ref<InstanceIconConfig[]>([])
const saving = ref(false)
const backgroundScroller = ref<HTMLElement | null>(null)
const showLeftBackgroundShadow = ref(false)
const showRightBackgroundShadow = ref(false)
const draggingBackgrounds = ref(false)
let backgroundScrollerResizeObserver: ResizeObserver | null = null
let backgroundDragPointerId: number | null = null
let backgroundDragCaptureTarget: Element | null = null
let backgroundDragStartX = 0
let backgroundDragStartScrollLeft = 0
let suppressBackgroundClick = false
let suppressBackgroundClickTimeout: ReturnType<typeof setTimeout> | null = null
const selectedBackground = ref<BackgroundId>(DEFAULT_BACKGROUND_ID)
const selectedSymbol = ref<SymbolId>(DEFAULT_SYMBOL_ID)
const selectedBackgroundOption = computed(
() => backgroundOptions.find((option) => option.id === selectedBackground.value)!,
)
const selectedSymbolOption = computed(
() => symbolOptions.find((option) => option.id === selectedSymbol.value)!,
)
const vanillaSymbolStartIndex = symbolOptions.findIndex((option) => option.category === 'vanilla')
const selectedConfig = computed<InstanceIconConfig>(() => ({
background: { ...selectedBackgroundOption.value.background },
symbol: selectedSymbol.value,
}))
const visibleRecentConfigs = computed(() =>
recentConfigs.value.filter(
(config) => backgroundOption(config.background) && symbolOption(config.symbol),
),
)
function updateBackgroundScrollShadows() {
const el = backgroundScroller.value
if (!el) {
showLeftBackgroundShadow.value = false
showRightBackgroundShadow.value = false
return
}
showLeftBackgroundShadow.value = el.scrollLeft > 0
showRightBackgroundShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
}
function onBackgroundWheel(event: WheelEvent) {
const el = backgroundScroller.value
if (!el || el.scrollWidth <= el.clientWidth) return
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
el.scrollLeft += delta
}
function onBackgroundPointerDown(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerType === 'touch' || event.button !== 0) return
backgroundDragPointerId = event.pointerId
backgroundDragStartX = event.clientX
backgroundDragStartScrollLeft = el.scrollLeft
suppressBackgroundClick = false
backgroundDragCaptureTarget =
event.target instanceof Element ? (event.target.closest('button') ?? el) : el
backgroundDragCaptureTarget.setPointerCapture(event.pointerId)
}
function onBackgroundPointerMove(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerId !== backgroundDragPointerId) return
const distance = event.clientX - backgroundDragStartX
if (!draggingBackgrounds.value && Math.abs(distance) < 4) return
draggingBackgrounds.value = true
suppressBackgroundClick = true
event.preventDefault()
el.scrollLeft = backgroundDragStartScrollLeft - distance
}
function finishBackgroundDrag(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerId !== backgroundDragPointerId) return
if (backgroundDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
backgroundDragCaptureTarget.releasePointerCapture(event.pointerId)
}
backgroundDragPointerId = null
backgroundDragCaptureTarget = null
draggingBackgrounds.value = false
if (suppressBackgroundClick) {
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
suppressBackgroundClickTimeout = setTimeout(() => {
suppressBackgroundClick = false
suppressBackgroundClickTimeout = null
}, 0)
}
}
function onBackgroundClick(event: MouseEvent) {
if (!suppressBackgroundClick) return
event.preventDefault()
event.stopPropagation()
suppressBackgroundClick = false
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
suppressBackgroundClickTimeout = null
}
onMounted(() => {
backgroundScrollerResizeObserver = new ResizeObserver(updateBackgroundScrollShadows)
if (backgroundScroller.value) backgroundScrollerResizeObserver.observe(backgroundScroller.value)
nextTick(updateBackgroundScrollShadows)
})
onBeforeUnmount(() => {
backgroundScrollerResizeObserver?.disconnect()
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
})
function backgroundOption(background?: IconBackground) {
if (background?.type !== 'linear-top-down-gradient') return undefined
return backgroundOptions.find(
(option) =>
option.background.top_color === background.top_color &&
option.background.bottom_color === background.bottom_color,
)
}
function backgroundStyle(background: IconBackground) {
if (background.type === 'color') return { backgroundColor: background.value }
return {
backgroundImage: `linear-gradient(to bottom, ${background.top_color}, ${background.bottom_color})`,
}
}
function backgroundKey(background: IconBackground) {
if (background.type === 'color') return `${background.type}-${background.value}`
return `${background.type}-${background.top_color}-${background.bottom_color}`
}
function symbolOption(symbol: string) {
return symbolOptions.find((option) => option.id === symbol)
}
function selectRecent(config: InstanceIconConfig) {
const background = backgroundOption(config.background)
const symbol = symbolOption(config.symbol)
if (!background || !symbol) return
selectedBackground.value = background.id
selectedSymbol.value = symbol.id
}
function surpriseMe() {
const configurations = backgroundOptions.flatMap((background) =>
symbolOptions
.filter(
(symbol) =>
background.id !== selectedBackground.value &&
symbol.id !== selectedSymbol.value &&
!RANDOM_CONFIG_BLACKLIST.some(
(config) => config.background === background.id && config.symbol === symbol.id,
),
)
.map((symbol) => ({ background: background.id, symbol: symbol.id })),
)
const configuration = configurations[Math.floor(Math.random() * configurations.length)]
if (!configuration) return
selectedBackground.value = configuration.background
selectedSymbol.value = configuration.symbol
}
async function loadRecents() {
try {
recentConfigs.value = await get_recent_icon_configs()
} catch (error) {
handleError(toError(error))
}
}
function show() {
selectedBackground.value = backgroundOption(props.config?.background)?.id ?? DEFAULT_BACKGROUND_ID
selectedSymbol.value = symbolOption(props.config?.symbol ?? '')?.id ?? DEFAULT_SYMBOL_ID
modal.value?.show()
void loadRecents()
nextTick(updateBackgroundScrollShadows)
}
function hide() {
modal.value?.hide()
}
async function loadSymbolBytes(asset: string): Promise<number[]> {
const response = await fetch(asset)
if (!response.ok) throw new Error('Failed to load the icon symbol.')
return Array.from(new Uint8Array(await response.arrayBuffer()))
}
async function saveIcon() {
if (saving.value) return
saving.value = true
try {
const config = selectedConfig.value
const symbolBytes = await loadSymbolBytes(selectedSymbolOption.value.asset)
const iconPath = props.instanceId
? await edit_generated_icon(props.instanceId, config, symbolBytes)
: await cache_generated_icon(config, symbolBytes, true)
emit('saved', iconPath, config)
saving.value = false
await nextTick()
hide()
} catch (error) {
handleError(toError(error))
} finally {
saving.value = false
}
}
async function randomizeAndSave() {
try {
surpriseMe()
const config = selectedConfig.value
const iconPath = await cache_generated_icon(
config,
await loadSymbolBytes(selectedSymbolOption.value.asset),
)
return { iconPath, config }
} catch (error) {
handleError(toError(error))
return null
}
}
async function applyGeneratedIcon(instanceId: string, config: InstanceIconConfig) {
try {
const symbol = symbolOption(config.symbol)
if (!backgroundOption(config.background) || !symbol) return false
await edit_generated_icon_if_empty(instanceId, config, await loadSymbolBytes(symbol.asset))
return true
} catch (error) {
handleError(toError(error))
return false
}
}
defineExpose({ show, hide, randomize: randomizeAndSave, randomizeAndSave, applyGeneratedIcon })
const messages = defineMessages({
title: {
id: 'instance.icon-editor.title',
defaultMessage: 'Icon editor',
},
background: {
id: 'instance.icon-editor.background',
defaultMessage: 'Background',
},
symbol: {
id: 'instance.icon-editor.symbol',
defaultMessage: 'Symbol',
},
surpriseMe: {
id: 'instance.icon-editor.surprise-me',
defaultMessage: 'Randomize',
},
recents: {
id: 'instance.icon-editor.recents',
defaultMessage: 'Recents',
},
description: {
id: 'instance.icon-editor.description',
defaultMessage: 'Mix and match elements to create a custom icon.',
},
saveIcon: {
id: 'instance.icon-editor.save',
defaultMessage: 'Save icon',
},
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="928px"
max-width="calc(100vw - 2rem)"
no-padding
actions-divider
:disable-close="saving"
>
<div class="flex h-[552px] max-h-[calc(100vh-168px)] min-h-0">
<aside
class="flex w-[244px] shrink-0 flex-col gap-4 overflow-y-auto border-0 border-r border-solid border-surface-5 p-6"
>
<div
class="flex w-full flex-col items-center gap-3 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-4"
>
<div
class="icon-outline relative size-[132px] overflow-hidden rounded-[20px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div class="flex items-center gap-2.5">
<div
class="icon-outline relative size-12 overflow-hidden rounded-2xl"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-8 overflow-hidden rounded-[10px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-4 overflow-hidden rounded-[5px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
</div>
</div>
<Button class="w-full !shadow-none" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</Button>
<div v-if="visibleRecentConfigs.length" class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">{{ formatMessage(messages.recents) }}</span>
<div class="grid grid-cols-4 gap-3">
<button
v-for="(recentConfig, index) in visibleRecentConfigs"
:key="`${backgroundKey(recentConfig.background)}-${recentConfig.symbol}`"
class="icon-outline relative size-10 cursor-pointer overflow-hidden rounded-xl border-0 p-0 transition-transform hover:scale-105"
:style="backgroundStyle(recentConfig.background)"
:aria-label="`${formatMessage(messages.recents)} ${index + 1}`"
@click="selectRecent(recentConfig)"
>
<img
:src="symbolOption(recentConfig.symbol)?.asset"
alt=""
class="size-full object-cover"
/>
</button>
</div>
</div>
</aside>
<div class="min-w-0 flex-1 overflow-y-auto bg-surface-2">
<section class="border-0 border-b border-solid border-surface-5 p-4">
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.background) }}
</h3>
<div class="relative">
<div
class="background-scroll-shadow-left pointer-events-none absolute bottom-0 -left-0.5 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
:class="showLeftBackgroundShadow ? 'opacity-100' : 'opacity-0'"
/>
<div
ref="backgroundScroller"
class="flex w-full select-none gap-2.5 overflow-x-auto overflow-y-hidden pb-2 pr-6"
:class="{ 'cursor-grabbing': draggingBackgrounds }"
@pointerdown="onBackgroundPointerDown"
@pointermove="onBackgroundPointerMove"
@pointerup="finishBackgroundDrag"
@pointercancel="finishBackgroundDrag"
@click.capture="onBackgroundClick"
@wheel.prevent="onBackgroundWheel"
@scroll="updateBackgroundScrollShadows"
>
<button
v-for="option in backgroundOptions"
:key="option.id"
class="icon-option icon-outline relative aspect-square w-[calc((100%_-_3.125rem)/6)] shrink-0 cursor-pointer rounded-[20px] border-0 p-0"
:class="{ 'icon-outline-selected': selectedBackground === option.id }"
:style="backgroundStyle(option.background)"
:aria-label="formatMessage(option.name)"
:aria-pressed="selectedBackground === option.id"
@click="selectedBackground = option.id"
>
<span
v-if="selectedBackground === option.id"
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
>
<CheckIcon class="size-4" />
</span>
</button>
</div>
<div
class="background-scroll-shadow-right pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
:class="showRightBackgroundShadow ? 'opacity-100' : 'opacity-0'"
/>
</div>
</section>
<section class="p-4">
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.symbol) }}
</h3>
<div class="grid grid-cols-6 gap-2.5">
<template v-for="(option, index) in symbolOptions" :key="option.id">
<hr
v-if="index === vanillaSymbolStartIndex"
class="col-span-6 my-2.5 mx-1 w-full border-0 border-t border-solid border-surface-5"
/>
<button
v-tooltip="{
content: formatMessage(option.name),
delay: { show: 500, hide: 0 },
}"
class="icon-option icon-outline relative aspect-square cursor-pointer overflow-hidden rounded-[20px] border-0 bg-transparent p-0"
:class="{ 'icon-outline-selected': selectedSymbol === option.id }"
:aria-label="formatMessage(option.name)"
:aria-pressed="selectedSymbol === option.id"
@click="selectedSymbol = option.id"
>
<img :src="option.asset" alt="" class="size-full object-cover" />
<span
v-if="selectedSymbol === option.id"
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
>
<CheckIcon class="size-4" />
</span>
</button>
</template>
</div>
</section>
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4 px-2">
<div class="flex min-w-0 items-center gap-2 text-primary">
<InfoIcon class="size-6 shrink-0 text-blue" />
<span>{{ formatMessage(messages.description) }}</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button :disabled="saving" type="outlined" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="saving" @click="saveIcon">
<SpinnerIcon v-if="saving" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(messages.saveIcon) }}
</Button>
</div>
</div>
</template>
</NewModal>
</template>
<style scoped>
.icon-outline {
outline: 1px solid color-mix(in srgb, var(--color-text-primary) 15%, transparent);
outline-offset: -1px;
}
.icon-option:not(.icon-outline-selected):hover {
outline-color: color-mix(in srgb, var(--color-text-primary) 30%, transparent);
}
.icon-option {
transition: outline-color 150ms ease;
}
.cursor-grabbing,
.cursor-grabbing * {
cursor: grabbing !important;
}
.icon-outline-selected {
outline-color: color-mix(in srgb, var(--color-text-primary) 60%, transparent);
}
.background-scroll-shadow-left {
-webkit-mask-image: linear-gradient(to right, black, transparent);
mask-image: linear-gradient(to right, black, transparent);
}
.background-scroll-shadow-right {
-webkit-mask-image: linear-gradient(to left, black, transparent);
mask-image: linear-gradient(to left, black, transparent);
}
</style>
@@ -0,0 +1,206 @@
<template>
<FloatingActionBar
:shown="selectedInstanceCount > 0"
:aria-label="formatMessage(messages.ariaLabel)"
hide-when-modal-open
>
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
{{ formatMessage(messages.selectedCount, { count: selectedLibraryInstances.size }) }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<Button
type="quiet"
class="!text-primary"
:disabled="busy"
@click="clearLibraryInstanceSelection"
>
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
</Button>
</div>
<div class="ml-auto flex items-center gap-0.5">
<Button
v-if="displayState.group === 'Group'"
type="quiet"
:disabled="busy"
@click="createGroupFromSelection"
>
<SquarePlusIcon />
<span class="bar-label">{{ formatMessage(messages.newGroup) }}</span>
</Button>
<Button
v-if="selectedGroupedInstances.length > 0"
type="quiet"
:disabled="busy"
@click="removeSelectedInstancesFromGroups"
>
<MinusIcon />
<span class="bar-label">{{ formatMessage(messages.removeFromGroup) }}</span>
</Button>
<div class="mx-1 h-6 w-px bg-surface-5" />
<Button
v-tooltip="deleting ? formatMessage(messages.deleting) : undefined"
type="quiet"
color="red"
interaction="filled"
:disabled="busy"
@click="confirmDeleteModal?.show()"
>
<TrashIcon />
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
</Button>
</div>
</FloatingActionBar>
<ConfirmDeleteInstanceModal
ref="confirmDeleteModal"
:instances="selectedInstances"
@delete="deleteSelectedInstances"
/>
</template>
<script setup lang="ts">
import { MinusIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
FloatingActionBar,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { toError } from '@/helpers/errors'
import { remove } from '@/helpers/instance'
import { set_group_memberships as setInstanceGroupMemberships } from '@/helpers/instance-groups'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const {
instances,
selectedLibraryInstances,
clearLibraryInstanceSelection,
setSelectedLibraryInstances,
creatingGroup,
createDefaultGroup,
displayState,
} = useLibrary()
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const deleting = ref(false)
const removingFromGroup = ref(false)
const selectedInstanceCount = computed(() => selectedLibraryInstances.value.size)
const selectedInstanceIds = computed(
() =>
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
)
const selectedInstances = computed(() =>
instances.value.filter((instance) => selectedInstanceIds.value.has(instance.id)),
)
const selectedGroupedInstances = computed(() =>
[...selectedLibraryInstances.value.values()].flatMap((selection) => {
const instance = instances.value.find((candidate) => candidate.id === selection.instanceId)
return instance?.group_ids.includes(selection.groupId) ? [{ instance, selection }] : []
}),
)
const busy = computed(() => deleting.value || removingFromGroup.value || creatingGroup.value)
const messages = defineMessages({
ariaLabel: {
id: 'app.library.selection.aria-label',
defaultMessage: 'Selected instances',
},
selectedCount: {
id: 'app.library.selection.selected-count',
defaultMessage: '{count} selected',
},
deleting: {
id: 'app.library.selection.deleting',
defaultMessage: 'Deleting selected instances',
},
newGroup: {
id: 'app.library.selection.new-group',
defaultMessage: 'New group',
},
removeFromGroup: {
id: 'app.library.selection.remove-from-group',
defaultMessage: 'Remove from group',
},
})
async function createGroupFromSelection() {
if (busy.value) return
const instanceIds = selectedInstanceIds.value
clearLibraryInstanceSelection()
await createDefaultGroup(instanceIds)
}
async function removeSelectedInstancesFromGroups() {
if (busy.value || selectedGroupedInstances.value.length === 0) return
removingFromGroup.value = true
const selectedGroupIdsByInstanceId = new Map<string, Set<string>>()
for (const { instance, selection } of selectedGroupedInstances.value) {
const groupIds = selectedGroupIdsByInstanceId.get(instance.id) ?? new Set()
groupIds.add(selection.groupId)
selectedGroupIdsByInstanceId.set(instance.id, groupIds)
}
const operations = [...selectedGroupIdsByInstanceId].flatMap(([instanceId, groupIds]) => {
const instance = instances.value.find((candidate) => candidate.id === instanceId)
return instance ? [{ instance, groupIds }] : []
})
try {
await setInstanceGroupMemberships(
operations.map(({ instance, groupIds }) => ({
instance_id: instance.id,
group_ids: instance.group_ids.filter((groupId) => !groupIds.has(groupId)),
})),
)
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
for (const { instance, groupIds } of operations) {
for (const groupId of groupIds) {
nextSelectedInstances.delete(
getLibraryInstanceSelectionKey({
instanceId: instance.id,
groupId,
}),
)
}
}
setSelectedLibraryInstances(nextSelectedInstances.values())
} catch (error) {
handleError(toError(error))
} finally {
removingFromGroup.value = false
}
}
async function deleteSelectedInstances() {
if (busy.value || selectedInstanceCount.value === 0) return
deleting.value = true
const instanceIds = [...selectedInstanceIds.value]
const results = await Promise.allSettled(instanceIds.map((instanceId) => remove(instanceId)))
const deletedInstanceIds = new Set<string>()
for (const [index, result] of results.entries()) {
if (result.status === 'rejected') {
handleError(toError(result.reason))
} else {
deletedInstanceIds.add(instanceIds[index])
}
}
setSelectedLibraryInstances(
[...selectedLibraryInstances.value.values()].filter(
(selection) => !deletedInstanceIds.has(selection.instanceId),
),
)
deleting.value = false
}
</script>
@@ -0,0 +1,150 @@
<template>
<NewModal
ref="modal"
no-padding
scrollable
actions-divider
max-width="560px"
width="560px"
:on-hide="closeGroupInstancesModal"
>
<template #title>
<span class="text-2xl font-semibold text-contrast">
{{
formatMessage(messages.title, {
groupName: groupInstancesModalGroup?.name ?? '',
})
}}
</span>
</template>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
<div class="px-6">
<StyledInput
v-model="groupInstancesSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="w-full"
/>
</div>
<div
v-if="groupInstances.length === 0"
class="flex items-center justify-center py-12 text-secondary"
>
{{ formatMessage(messages.noInstancesFound) }}
</div>
<div v-else class="flex flex-col gap-1">
<div
v-for="instance in groupInstances"
:key="instance.id"
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
:class="{ 'opacity-60': selectedGroupInstanceIds.has(instance.id) }"
>
<div class="flex min-w-0 items-center gap-2.5">
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
:alt="instance.name"
size="2rem"
rounded="md"
/>
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
</div>
<Button
:type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
@click="toggleGroupInstance(instance.id)"
>
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
{{
formatMessage(
selectedGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
)
}}
</Button>
</div>
</div>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="savingGroupInstances" @click="save">
<SpinnerIcon v-if="savingGroupInstances" class="animate-spin" />
<CheckIcon v-else />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
commonMessages,
defineMessages,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
import { getInstanceIconUrl } from '@/helpers/instance'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.library.group.instances-modal.title',
defaultMessage: 'Add instances to "{groupName}"',
},
searchPlaceholder: {
id: 'app.library.group.instances-modal.search-placeholder',
defaultMessage: 'Search instance',
},
noInstancesFound: {
id: 'app.library.group.instances-modal.no-instances-found',
defaultMessage: 'No instances found',
},
add: {
id: 'app.library.group.instances-modal.add',
defaultMessage: 'Add',
},
added: {
id: 'app.library.group.instances-modal.added',
defaultMessage: 'Added',
},
})
const {
isGroupInstancesModalOpen,
groupInstancesModalGroup,
groupInstancesSearch,
groupInstances,
selectedGroupInstanceIds,
savingGroupInstances,
closeGroupInstancesModal,
toggleGroupInstance,
saveGroupInstances,
} = useLibrary()
const modal = ref<InstanceType<typeof NewModal>>()
watch(isGroupInstancesModalOpen, (open) => {
if (open) {
modal.value?.show()
}
})
async function save() {
if (await saveGroupInstances()) {
modal.value?.hide()
}
}
</script>
@@ -0,0 +1,450 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
EyeIcon,
FolderOpenIcon,
MinusIcon,
PlayIcon,
PlusIcon,
StarIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
import LibraryToolbar from '@/components/ui/library/library-toolbar/index.vue'
import LibrarySelectionActionBar from '@/components/ui/library/LibrarySelectionActionBar.vue'
import {
getLibraryInstanceSelectionKey,
type InstanceGroup as InstanceGroupType,
provideLibrary,
} from '@/components/ui/library/use-library'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { FAVORITES_GROUP_ID } from '@/helpers/instance-groups'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
instances: GameInstance[]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
library: { id: 'app.library.title', defaultMessage: 'Library' },
noSearchResults: {
id: 'app.library.search.no-results.title',
defaultMessage: 'No instances match your search.',
},
play: { id: 'app.library.instance.action.play', defaultMessage: 'Play' },
stop: { id: 'app.library.instance.action.stop', defaultMessage: 'Stop' },
addToFavorites: {
id: 'app.library.instance.action.add-to-favorites',
defaultMessage: 'Add to favorites',
},
removeFromFavorites: {
id: 'app.library.instance.action.remove-from-favorites',
defaultMessage: 'Remove from favorites',
},
addContent: { id: 'app.library.instance.action.add-content', defaultMessage: 'Add content' },
viewInstance: {
id: 'app.library.instance.action.view-instance',
defaultMessage: 'View instance',
},
duplicateInstance: {
id: 'app.library.instance.action.duplicate',
defaultMessage: 'Duplicate instance',
},
delete: { id: 'app.library.instance.action.delete', defaultMessage: 'Delete' },
openFolder: { id: 'app.library.instance.action.open-folder', defaultMessage: 'Open folder' },
copyPath: { id: 'app.library.instance.action.copy-path', defaultMessage: 'Copy path' },
removeFromGroup: {
id: 'app.library.instance.action.remove-from-group',
defaultMessage: 'Remove from group',
},
})
const {
instanceGroups,
libraryGroupsLoaded,
isSearching,
displayState,
filters,
reorderingGroups,
reorderGroups,
instanceOptions,
confirmDeleteModal,
currentDeleteInstances,
clearLibraryInstanceSelection,
deleteInstance,
handleInstanceOption,
selectedLibraryInstances,
setSelectedLibraryInstances,
toggleLibraryInstanceSelection,
} = provideLibrary(toRef(props, 'instances'))
const hasActiveFilters = computed(() =>
Object.values(filters.value).some((selectedValues) => selectedValues.length > 0),
)
const visibleInstanceGroups = computed(() =>
instanceGroups.value.filter((instanceGroup) =>
instanceGroup.id === FAVORITES_GROUP_ID
? instanceGroup.instances.length > 0
: instanceGroup.instances.length > 0 ||
(!isSearching.value && !hasActiveFilters.value && instanceGroup.key !== 'None'),
),
)
const visibleCustomGroups = computed(() =>
displayState.value.group === 'Group'
? visibleInstanceGroups.value.filter(
(group) => group.id !== FAVORITES_GROUP_ID && group.id !== 'group:none',
)
: [],
)
const visibleFavoritesGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
)
const visibleUngroupedGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === 'group:none'),
)
const draggableCustomGroups = ref<InstanceGroupType[]>([])
const libraryGroupsContainer = ref<HTMLElement>()
const isDraggingGroup = ref(false)
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
const canDragReorderGroups = computed(
() => !reorderingGroups.value && draggableCustomGroups.value.length > 1,
)
watch(
visibleCustomGroups,
(groups) => {
if (!isDraggingGroup.value) {
const previousGroupTops = getCustomGroupTops()
draggableCustomGroups.value = [...groups]
void nextTick(() => animateCustomGroupReorder(previousGroupTops))
}
},
{ immediate: true },
)
function getCustomGroupTops() {
const groupTops = new Map<string, number>()
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
'[data-instance-group-reorder-id]',
)
for (const groupElement of groupElements ?? []) {
const groupId = groupElement.dataset.instanceGroupReorderId
if (groupId) {
groupTops.set(groupId, groupElement.getBoundingClientRect().top)
}
}
return groupTops
}
function animateCustomGroupReorder(previousGroupTops: Map<string, number>) {
if (
previousGroupTops.size === 0 ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
return
}
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
'[data-instance-group-reorder-id]',
)
for (const groupElement of groupElements ?? []) {
const groupId = groupElement.dataset.instanceGroupReorderId
const previousTop = groupId ? previousGroupTops.get(groupId) : undefined
if (previousTop === undefined) continue
const offset = previousTop - groupElement.getBoundingClientRect().top
if (Math.abs(offset) < 1) continue
groupElement.animate(
[{ transform: `translateY(${offset}px)` }, { transform: 'translateY(0)' }],
{ duration: 200, easing: 'ease-out' },
)
}
}
function onGroupDragStart() {
isDraggingGroup.value = true
document.documentElement.classList.add(GROUP_REORDERING_CLASS)
}
function onGroupDragEnd() {
isDraggingGroup.value = false
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
const currentGroupIds = visibleCustomGroups.value.map((group) => group.id)
const orderedGroupIds = draggableCustomGroups.value.map((group) => group.id)
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
draggableCustomGroups.value = [...visibleCustomGroups.value]
return
}
void reorderGroups(orderedGroupIds)
}
onUnmounted(() => {
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
})
onDeactivated(clearLibraryInstanceSelection)
const anchorInstance = ref<{ groupId: string; instanceId: string } | null>(null)
function handleToggleInstance(groupId: string, instanceId: string, shiftKey: boolean) {
const displayedInstances = visibleInstanceGroups.value.flatMap((group) =>
group.instances.map((instance) => ({
groupId: group.id,
instanceId: instance.id,
})),
)
const anchor = anchorInstance.value
if (shiftKey && anchor && displayedInstances.length) {
const anchorIndex = displayedInstances.findIndex(
(instance) =>
instance.groupId === anchor.groupId && instance.instanceId === anchor.instanceId,
)
const targetIndex = displayedInstances.findIndex(
(instance) => instance.groupId === groupId && instance.instanceId === instanceId,
)
if (anchorIndex === -1 || targetIndex === -1) {
toggleLibraryInstanceSelection({ groupId, instanceId })
return
}
const start = Math.min(anchorIndex, targetIndex)
const end = Math.max(anchorIndex, targetIndex)
const range = displayedInstances.slice(start, end + 1)
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
const targetKey = getLibraryInstanceSelectionKey({ groupId, instanceId })
if (nextSelectedInstances.has(targetKey)) {
for (const instance of range) {
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(instance))
}
} else {
for (const instance of range) {
nextSelectedInstances.set(getLibraryInstanceSelectionKey(instance), instance)
}
}
setSelectedLibraryInstances(nextSelectedInstances.values())
anchorInstance.value = null
return
}
toggleLibraryInstanceSelection({ groupId, instanceId })
anchorInstance.value = { groupId, instanceId }
}
function setInstanceOptions(component: unknown) {
instanceOptions.value = component as InstanceType<typeof ContextMenu> | null
}
function setConfirmDeleteModal(component: unknown) {
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
}
watch(selectedLibraryInstances, (selectedInstances) => {
if (selectedInstances.size === 0) {
anchorInstance.value = null
return
}
if (
anchorInstance.value &&
!selectedInstances.has(getLibraryInstanceSelectionKey(anchorInstance.value))
) {
anchorInstance.value = null
}
})
</script>
<template>
<InstanceGroupDnd :instances="instances">
<section data-library-page-background class="flex flex-col gap-3 pb-16 min-h-[500px]">
<h2 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.library) }}
</h2>
<LibraryToolbar />
<div
v-if="libraryGroupsLoaded && isSearching && visibleInstanceGroups.length === 0"
class="text-base text-primary"
>
{{ formatMessage(messages.noSearchResults) }}
</div>
<Transition
v-else
enter-active-class="transition-opacity duration-200 ease-out motion-reduce:transition-none"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
>
<div
v-if="libraryGroupsLoaded && displayState.group === 'Group'"
ref="libraryGroupsContainer"
data-library-page-background
class="flex flex-col"
>
<div v-if="visibleFavoritesGroup" class="min-w-0">
<InstanceGroup
:instance-group="visibleFavoritesGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === FAVORITES_GROUP_ID ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance(FAVORITES_GROUP_ID, instanceId, shiftKey)
"
/>
</div>
<Draggable
:list="draggableCustomGroups"
class="flex flex-col"
item-key="id"
:disabled="!canDragReorderGroups"
:animation="250"
:swap-threshold="0.75"
:invert-swap="true"
:force-fallback="true"
:fallback-on-body="true"
:fallback-tolerance="4"
filter=".instance-group-reorder-ignore, input, textarea, [contenteditable='true']"
handle=".instance-group-reorder-handle"
:prevent-on-filter="false"
ghost-class="instance-group-reorder-ghost"
chosen-class="instance-group-reorder-chosen"
drag-class="instance-group-reorder-drag"
fallback-class="instance-group-reorder-fallback"
@start="onGroupDragStart"
@end="onGroupDragEnd"
>
<template #item="{ element: instanceGroup }">
<div
:key="instanceGroup.id"
class="min-w-0 w-full"
:data-instance-group-reorder-id="instanceGroup.id"
>
<InstanceGroup
:can-drag-reorder="canDragReorderGroups"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
"
/>
</div>
</template>
</Draggable>
<div v-if="visibleUngroupedGroup" class="min-w-0">
<InstanceGroup
:hide-header="visibleInstanceGroups.length === 1"
:instance-group="visibleUngroupedGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === 'group:none' ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance('group:none', instanceId, shiftKey)
"
/>
</div>
</div>
<TransitionGroup
v-else-if="libraryGroupsLoaded"
data-library-page-background
tag="div"
class="flex flex-col"
move-class="transition-transform duration-200 ease-out"
enter-active-class="transition-[opacity,transform] duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
>
<div
v-for="instanceGroup in visibleInstanceGroups"
:key="instanceGroup.id"
class="min-w-0"
>
<InstanceGroup
:hide-header="instanceGroup.key === 'None' && visibleInstanceGroups.length === 1"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
"
/>
</div>
</TransitionGroup>
</Transition>
</section>
</InstanceGroupDnd>
<LibrarySelectionActionBar />
<GroupInstancesModal />
<ConfirmDeleteInstanceModal
:ref="setConfirmDeleteModal"
:instances="currentDeleteInstances"
@delete="deleteInstance"
/>
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
<template #add_to_favorites>
<StarIcon /> {{ formatMessage(messages.addToFavorites) }}
</template>
<template #remove_from_favorites>
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" />
{{ formatMessage(messages.removeFromFavorites) }}
</template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #duplicate>
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
</template>
<template #delete> <TrashIcon /> {{ formatMessage(messages.delete) }} </template>
<template #open> <FolderOpenIcon /> {{ formatMessage(messages.openFolder) }} </template>
<template #copy> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
<template #remove_from_group>
<MinusIcon /> {{ formatMessage(messages.removeFromGroup) }}
</template>
</ContextMenu>
</template>
<style scoped>
:global(.instance-group-reorder-ghost) {
opacity: 0.35;
}
:global(html.instance-group-reordering),
:global(html.instance-group-reordering *) {
-webkit-user-select: none !important;
cursor: grabbing !important;
user-select: none !important;
}
:global(.instance-group-reorder-fallback) {
opacity: 0.9;
pointer-events: none;
}
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import DragPreview from '@/components/ui/library/instance-group/drag-preview.vue'
import {
gatherDuration,
type InstanceDragGatherItem,
} from '@/components/ui/library/instance-group/use-instance-drag-gather'
const props = defineProps<{
items: InstanceDragGatherItem[]
target: {
x: number
y: number
}
}>()
const emit = defineEmits<{
complete: []
}>()
const gathered = ref(false)
const finishing = ref(false)
let animationFrame: number | undefined
let completionTimer: number | undefined
const itemContainerStyles = computed(() =>
props.items.map((_, index) => ({
left: `${props.target.x}px`,
top: `${props.target.y}px`,
zIndex: `${index}`,
})),
)
const itemStyles = computed(() =>
props.items.map((item, index) => {
const originX = item.rect.left - props.target.x
const originY = item.rect.top - props.target.y
return {
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transform: gathered.value
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
: `translate3d(${originX}px, ${originY}px, 0)`,
transitionDuration: `${gatherDuration}ms`,
transitionDelay: `${Math.min(index, 8) * 10}ms`,
}
}),
)
onMounted(() => {
animationFrame = requestAnimationFrame(() => {
animationFrame = requestAnimationFrame(() => {
gathered.value = true
completionTimer = window.setTimeout(
() => {
finishing.value = true
emit('complete')
},
Math.max(gatherDuration - 250, 0),
)
})
})
})
onBeforeUnmount(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
if (completionTimer !== undefined) {
clearTimeout(completionTimer)
}
})
</script>
<template>
<Teleport to="body">
<div
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
:class="finishing ? 'opacity-0' : 'opacity-100'"
aria-hidden="true"
>
<div
v-for="(item, index) in items"
:key="item.instance.id"
class="fixed"
:style="itemContainerStyles[index]"
>
<div
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
:style="itemStyles[index]"
>
<DragPreview :instance="item.instance" />
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { TagItem } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
import { gatherDuration } from '@/components/ui/library/instance-group/use-instance-drag-gather'
import type { GameInstance } from '@/helpers/types'
const props = withDefaults(
defineProps<{
instance: GameInstance
count?: number
}>(),
{
count: 1,
},
)
const showGatheredCount = ref(false)
let countTimer: number | undefined
onMounted(() => {
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showGatheredCount.value = true
return
}
countTimer = window.setTimeout(() => {
showGatheredCount.value = true
countTimer = undefined
}, gatherDuration - 150)
})
onBeforeUnmount(() => {
if (countTimer !== undefined) {
clearTimeout(countTimer)
}
})
</script>
<template>
<div aria-hidden="true" class="relative w-full select-none">
<div
v-if="count > 1"
class="absolute inset-x-3 -bottom-2 top-2 rounded-[20px] border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
/>
<div
v-if="count > 1"
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-[20px] border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
/>
<InstanceCardView :instance="instance" class="opacity-90 shadow-lg">
<template #overlay>
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-2 font-semibold tabular-nums text-contrast motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
>
{{ count }}
</TagItem>
</template>
</InstanceCardView>
</div>
</template>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
defineProps<{
deleting?: boolean
canMoveDown: boolean
canMoveUp: boolean
onAddToGroup: () => void
onDeleteGroup: () => void
onEditGroupName: () => void
onMoveDown: () => void
onMoveUp: () => void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
addToGroup: {
id: 'app.library.group.context-menu.add-to-group',
defaultMessage: 'Add to group',
},
editGroupName: {
id: 'app.library.group.edit-name',
defaultMessage: 'Edit group name',
},
deleteGroup: {
id: 'app.library.group.delete',
defaultMessage: 'Delete group',
},
moveGroupUp: {
id: 'app.library.group.move-up',
defaultMessage: 'Move group up',
},
moveGroupDown: {
id: 'app.library.group.move-down',
defaultMessage: 'Move group down',
},
})
</script>
<template>
<div
class="instance-group-reorder-ignore flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
>
<IconButton
v-tooltip="formatMessage(messages.moveGroupUp)"
:label="formatMessage(messages.moveGroupUp)"
type="quiet"
size="sm"
:disabled="!canMoveUp"
@click.stop="onMoveUp"
>
<ArrowUpIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moveGroupDown)"
:label="formatMessage(messages.moveGroupDown)"
type="quiet"
size="sm"
:disabled="!canMoveDown"
@click.stop="onMoveDown"
>
<ArrowDownIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.editGroupName)"
:label="formatMessage(messages.editGroupName)"
type="quiet"
size="sm"
@click.stop="onEditGroupName"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.addToGroup)"
:label="formatMessage(messages.addToGroup)"
type="quiet"
size="sm"
@click.stop="onAddToGroup"
>
<SquarePlusIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
size="sm"
:disabled="deleting"
@click.stop="onDeleteGroup"
>
<TrashIcon />
</IconButton>
</div>
</template>
@@ -0,0 +1,547 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import {
DropdownIcon,
EditIcon,
PlusIcon,
SquarePlusIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Accordion,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
InlineEditableText,
NewModal,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
import type {
InstanceCard as InstanceCardExposed,
InstanceGroup as InstanceGroupType,
} from '@/components/ui/library/use-library'
import { useLibrary } from '@/components/ui/library/use-library'
import { FAVORITES_GROUP_ID, MAX_INSTANCE_GROUP_NAME_LENGTH } from '@/helpers/instance-groups'
const INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY = 500
const props = withDefaults(
defineProps<{
canDragReorder?: boolean
hideHeader?: boolean
instanceGroup: InstanceGroupType
selectionAnchorInstanceId?: string | null
}>(),
{
canDragReorder: false,
hideHeader: false,
selectionAnchorInstanceId: null,
},
)
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const {
isSectionCollapsed,
setSectionCollapsed,
isSearching,
deleteGroup,
renameGroup,
canMoveGroupUp,
canMoveGroupDown,
moveGroup,
groupIdPendingNameEdit,
completePendingGroupNameEdit,
handleInstanceContextMenu,
openGroupInstancesModal,
displayState,
activeInstanceGroupDrag,
instanceGroupDragTarget,
getInstanceGroupDropState,
} = useLibrary()
const showCreationModal = inject<() => void>('showCreationModal')
const instanceComponents = new Map<string, InstanceCardExposed>()
const groupDropTarget = ref<HTMLElement>()
const groupAccordion = ref<InstanceType<typeof Accordion>>()
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
const groupNameInput = ref<InstanceType<typeof InlineEditableText>>()
const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
const instanceGridContent = ref<HTMLElement>()
const instanceGridHeight = ref<number>()
const deletingGroup = ref(false)
const groupName = ref(props.instanceGroup.key)
const isUngrouped = computed(() => props.instanceGroup.id === 'group:none')
const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID)
const isCustomGroup = computed(
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
)
const groupContextMenuOpen = ref(false)
const isGroupToggleBlocked = computed(
() => isSearching.value || groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
)
let shouldSkipGroupToggle = false
let groupToggleEventToSkip: MouseEvent | undefined
let instanceGridResizeObserver: ResizeObserver | undefined
let instanceGridObserverActivationTimeout: ReturnType<typeof setTimeout> | undefined
const emit = defineEmits<{
(e: 'toggle-selection', instanceId: string, shiftKey: boolean): void
}>()
useDroppable({
id: computed(() => `instance-group:${props.instanceGroup.id}`),
element: groupDropTarget,
disabled: computed(() => displayState.value.group !== 'Group'),
data: computed(() => ({
groupId: props.instanceGroup.id,
})),
})
const messages = defineMessages({
ungrouped: {
id: 'app.library.group.ungrouped',
defaultMessage: 'Ungrouped',
},
favorites: {
id: 'app.library.group.favorites',
defaultMessage: 'Favorites',
},
collapseGroup: { id: 'app.library.group.collapse', defaultMessage: 'Collapse group' },
expandGroup: { id: 'app.library.group.expand', defaultMessage: 'Expand group' },
deleteGroup: {
id: 'app.library.group.delete',
defaultMessage: 'Delete group',
},
editGroupName: {
id: 'app.library.group.edit-name',
defaultMessage: 'Edit group name',
},
renameGroupFailed: {
id: 'app.library.group.rename-failed',
defaultMessage: 'Unable to rename group',
},
groupNameEmpty: {
id: 'app.library.group.name-empty',
defaultMessage: 'Group names cannot be empty.',
},
groupNameTooLong: {
id: 'app.library.group.name-too-long',
defaultMessage: 'Group names cannot be longer than {maxLength} characters.',
},
groupNameReserved: {
id: 'app.library.group.name-reserved',
defaultMessage: '"None" is reserved and cannot be used as a group name.',
},
deleteGroupDescription: {
id: 'app.library.group.delete-description',
defaultMessage: 'Instances in this group will be ungrouped.',
},
newInstance: {
id: 'app.library.context-menu.create-instance',
defaultMessage: 'New instance',
},
addToGroup: {
id: 'app.library.group.context-menu.add-to-group',
defaultMessage: 'Add to group',
},
emptyGroup: {
id: 'app.library.group.empty',
defaultMessage: 'Drag and drop to add instances.',
},
})
function openInstanceContextMenu(event: MouseEvent, instanceId: string, instanceGroupId: string) {
const instanceComponent = instanceComponents.get(instanceId)
if (!instanceComponent) return
handleInstanceContextMenu(event, instanceComponent, instanceGroupId)
}
function setInstanceComponent(instanceId: string, component: unknown) {
if (component) {
instanceComponents.set(instanceId, component as InstanceCardExposed)
} else {
instanceComponents.delete(instanceId)
}
}
async function removeGroup() {
if (deletingGroup.value) return
deletingGroup.value = true
const deleted = await deleteGroup(props.instanceGroup.id)
deletingGroup.value = false
if (deleted) {
confirmDeleteGroupModal.value?.hide()
}
}
function requestGroupDeletion() {
if (isUngrouped.value || isFavorites.value) return
if (props.instanceGroup.instances.length > 0) {
confirmDeleteGroupModal.value?.show()
} else {
void removeGroup()
}
}
function openGroupContextMenu(event: MouseEvent) {
groupContextMenuOpen.value = true
groupOptions.value?.showMenu(
event,
props.instanceGroup,
isFavorites.value
? [{ name: 'new_instance' }]
: isCustomGroup.value
? [
{ name: 'new_instance' },
{ name: 'add_instances' },
{ name: 'edit_name' },
{ type: 'divider' },
{ name: 'delete_group', color: 'danger' },
]
: [{ name: 'new_instance' }],
)
}
function handleGroupOption({ option }: { option: string }) {
if (option === 'new_instance') {
showCreationModal?.()
return
}
if (option === 'add_instances') {
openGroupInstancesModal(props.instanceGroup.id)
return
}
if (option === 'edit_name') {
void groupNameInput.value?.startEditing()
return
}
if (option === 'delete_group') {
requestGroupDeletion()
}
}
function prepareGroupToggle(event: PointerEvent) {
const editor = groupNameInput.value
const contextMenuWasOpen = groupContextMenuOpen.value
if (contextMenuWasOpen) {
groupOptions.value?.hideMenu()
}
shouldSkipGroupToggle = Boolean(
contextMenuWasOpen ||
(editor?.isEditing && event.target instanceof Node && !editor.$el.contains(event.target)),
)
}
function captureGroupClick(event: MouseEvent) {
groupToggleEventToSkip = shouldSkipGroupToggle ? event : undefined
shouldSkipGroupToggle = false
}
function toggleGroup(event: MouseEvent) {
if (event === groupToggleEventToSkip) {
groupToggleEventToSkip = undefined
return
}
if (isSearching.value) return
if (groupAccordion.value?.isOpen) {
groupAccordion.value.close()
} else {
groupAccordion.value?.open()
}
}
function validateGroupName(value: string) {
const normalizedGroupName = value.trim()
let reason: string | undefined
if (normalizedGroupName.length === 0) {
reason = formatMessage(messages.groupNameEmpty)
} else if (normalizedGroupName.length > MAX_INSTANCE_GROUP_NAME_LENGTH) {
reason = formatMessage(messages.groupNameTooLong, {
maxLength: MAX_INSTANCE_GROUP_NAME_LENGTH,
})
} else if (normalizedGroupName.toLowerCase() === 'none') {
reason = formatMessage(messages.groupNameReserved)
}
if (reason) {
addNotification({
type: 'error',
title: formatMessage(messages.renameGroupFailed),
text: reason,
})
return false
}
return true
}
async function updateGroupName(value: string) {
return await renameGroup(props.instanceGroup.id, value)
}
watch(
() => props.instanceGroup.key,
(value) => {
groupName.value = value
},
)
watch(
[() => props.instanceGroup.id, groupIdPendingNameEdit],
async ([groupId, pendingGroupId]) => {
if (groupId !== pendingGroupId) return
await nextTick()
groupDropTarget.value?.scrollIntoView({ block: 'nearest' })
await groupNameInput.value?.startEditing()
completePendingGroupNameEdit(groupId)
},
{ immediate: true, flush: 'post' },
)
watch(
() => props.hideHeader,
(hideHeader) => {
if (hideHeader) {
groupAccordion.value?.open()
}
},
{ flush: 'post' },
)
function stopInstanceGridResizeObserver() {
clearTimeout(instanceGridObserverActivationTimeout)
instanceGridResizeObserver?.disconnect()
}
function startInstanceGridResizeObserver() {
stopInstanceGridResizeObserver()
instanceGridHeight.value = undefined
instanceGridObserverActivationTimeout = setTimeout(() => {
instanceGridObserverActivationTimeout = undefined
const gridContent = instanceGridContent.value
if (!gridContent?.isConnected) return
instanceGridHeight.value = gridContent.getBoundingClientRect().height
instanceGridResizeObserver = new ResizeObserver(() => {
if (!gridContent.isConnected) return
instanceGridHeight.value = gridContent.getBoundingClientRect().height
})
instanceGridResizeObserver.observe(gridContent)
}, INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY)
}
onActivated(startInstanceGridResizeObserver)
onDeactivated(stopInstanceGridResizeObserver)
onMounted(startInstanceGridResizeObserver)
</script>
<template>
<div
ref="groupDropTarget"
class="instance-group group/instance-container relative select-none pb-3"
@contextmenu.prevent.stop="openGroupContextMenu"
>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="!opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="!opacity-0"
>
<div
v-if="
activeInstanceGroupDrag &&
instanceGroupDragTarget === instanceGroup.id &&
getInstanceGroupDropState(instanceGroup.id).canDrop
"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 opacity-40 border-dashed border-contrast bg-transparent"
/>
</Transition>
<div
v-if="!hideHeader"
class="group/header h-10 flex w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
:class="{
'instance-group-reorder-handle': isCustomGroup && canDragReorder,
}"
>
<div
class="group/open-target flex min-w-0 items-center gap-2"
:class="isGroupToggleBlocked ? 'cursor-default' : 'cursor-pointer'"
@click="toggleGroup"
@click.capture="captureGroupClick"
@pointerdown.capture="prepareGroupToggle"
>
<button
class="flex shrink-0 items-center border-0 bg-transparent p-0"
:class="isGroupToggleBlocked ? 'cursor-default' : 'cursor-pointer'"
type="button"
:aria-expanded="groupAccordion?.isOpen"
:aria-label="
formatMessage(groupAccordion?.isOpen ? messages.collapseGroup : messages.expandGroup)
"
@click.stop="toggleGroup"
>
<DropdownIcon
class="size-5 shrink-0 text-secondary transition-all duration-300 group-hover/open-target:text-primary"
:class="{ 'rotate-180': groupAccordion?.isOpen }"
/>
</button>
<InlineEditableText
v-if="!isUngrouped && !isFavorites"
ref="groupNameInput"
v-model="groupName"
activation-mode="manual"
class="text-base font-semibold !h-10 text-primary select-none group-hover/open-target:text-contrast"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
icon-text-class="select-none"
:max-length="MAX_INSTANCE_GROUP_NAME_LENGTH"
:on-change="updateGroupName"
:validate="validateGroupName"
/>
<span
v-else-if="isUngrouped"
class="text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
>
{{ formatMessage(messages.ungrouped) }}
</span>
<span
v-else
class="flex items-center gap-1.5 text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
>
{{ formatMessage(messages.favorites) }}
</span>
<TagItem
v-if="instanceGroup.instances.length"
class="shrink-0 border-surface-3 bg-surface-2"
>
{{ instanceGroup.instances.length }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<GroupActionButtons
v-if="isCustomGroup"
:can-move-down="canMoveGroupDown(instanceGroup.id)"
:can-move-up="canMoveGroupUp(instanceGroup.id)"
:deleting="deletingGroup"
:on-add-to-group="() => openGroupInstancesModal(instanceGroup.id)"
:on-delete-group="requestGroupDeletion"
:on-edit-group-name="() => groupNameInput?.startEditing()"
:on-move-down="() => moveGroup(instanceGroup.id, 1)"
:on-move-up="() => moveGroup(instanceGroup.id, -1)"
/>
</div>
<Accordion
ref="groupAccordion"
:open-by-default="hideHeader || !isSectionCollapsed(instanceGroup.id)"
:force-open="isSearching"
class="w-full"
@on-open="setSectionCollapsed(instanceGroup.id, false)"
@on-close="setSectionCollapsed(instanceGroup.id, true)"
>
<div
class="mt-2.5 overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none"
:style="{
height: instanceGridHeight === undefined ? undefined : `${instanceGridHeight}px`,
}"
>
<div ref="instanceGridContent">
<TransitionGroup
tag="section"
class="grid min-h-[45px] w-full grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))] max-xl:grid-cols-[repeat(auto-fill,minmax(min(8rem,100%),1fr))] gap-3 overflow-y-auto scroll-smooth"
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
enter-active-class="transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none"
enter-from-class="opacity-0"
enter-to-class="opacity-100 scale-100"
>
<div
v-for="instance in instanceGroup.instances"
:key="instance.id"
class="min-w-0 w-full"
>
<InstanceCard
:ref="(component: unknown) => setInstanceComponent(instance.id, component)"
:instance="instance"
:instance-group-id="instanceGroup.id"
:is-selection-anchor="selectionAnchorInstanceId === instance.id"
@toggle-selection="
(shiftKey: boolean) => emit('toggle-selection', instance.id, shiftKey)
"
@contextmenu.prevent.stop="
(event: MouseEvent) =>
openInstanceContextMenu(event, instance.id, instanceGroup.id)
"
/>
</div>
<p
v-if="instanceGroup.instances.length === 0"
key="empty-group"
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-secondary opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</TransitionGroup>
</div>
</div>
</Accordion>
</div>
<ContextMenu
ref="groupOptions"
@menu-closed="groupContextMenuOpen = false"
@option-clicked="handleGroupOption"
>
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
<template #add_instances>
<SquarePlusIcon /> {{ formatMessage(messages.addToGroup) }}
</template>
<template #edit_name> <EditIcon /> {{ formatMessage(messages.editGroupName) }} </template>
<template #delete_group> <TrashIcon /> {{ formatMessage(messages.deleteGroup) }} </template>
</ContextMenu>
<NewModal
ref="confirmDeleteGroupModal"
:header="formatMessage(messages.deleteGroup)"
fade="danger"
width="500px"
>
<p class="m-0 text-base text-primary">
{{ formatMessage(messages.deleteGroupDescription) }}
</p>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="confirmDeleteGroupModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="red" :disabled="deletingGroup" @click="removeGroup">
<TrashIcon />
{{ formatMessage(messages.deleteGroup) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { Avatar, truncatedTooltip } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getInstanceIconUrl } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
const props = withDefaults(
defineProps<{
instance: GameInstance
selected?: boolean
}>(),
{
selected: false,
},
)
const iconSrc = computed(() => getInstanceIconUrl(props.instance.icon_path))
const nameRef = ref<HTMLElement | null>(null)
const versionRef = ref<HTMLElement | null>(null)
</script>
<template>
<div
class="relative flex w-full min-w-0 select-none flex-col items-start justify-end gap-3 overflow-clip rounded-[20px] border border-solid bg-surface-3 p-3 text-left transition-all"
:class="{
'[border-color:color-mix(in_srgb,var(--color-text-primary)_40%,transparent)] brightness-110':
selected,
'border-surface-4': !selected,
}"
>
<div
class="relative flex aspect-square min-w-full shrink-0 items-center overflow-clip rounded-2xl"
>
<Avatar
class="pointer-events-none !rounded-2xl outline-none"
size="100%"
:src="iconSrc"
:tint-by="instance.id"
alt=""
no-shadow
/>
<slot name="loading" />
<div class="absolute bottom-1.5 right-1.5 z-[1] flex size-12 items-center justify-center">
<slot name="leading" />
</div>
</div>
<div class="flex min-w-0 w-full flex-col items-start justify-center gap-1 px-0.5">
<p
ref="nameRef"
v-tooltip="truncatedTooltip(nameRef, instance.name)"
class="m-0 w-full truncate text-base font-semibold leading-5 text-contrast"
>
{{ instance.name }}
</p>
<p
ref="versionRef"
v-tooltip="truncatedTooltip(versionRef, `${instance.loader} ${instance.game_version}`)"
class="m-0 w-full truncate text-sm font-medium capitalize leading-[18px] text-primary"
>
{{ instance.loader }} {{ instance.game_version }}
</p>
</div>
<slot name="overlay" />
</div>
</template>
@@ -0,0 +1,379 @@
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, DownloadIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import { defineMessages, IconButton, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useEventListener, useMagicKeys } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
type ProcessEvent = 'installing' | 'launched' | 'finished'
const LOADING_INDICATOR_DELAY_MS = 100
const instanceCardSensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
selectInstance: {
id: 'app.library.instance.select-with-name',
defaultMessage: 'Select {name}',
},
deselectInstance: {
id: 'app.library.instance.deselect-with-name',
defaultMessage: 'Deselect {name}',
},
openInstance: {
id: 'app.library.instance.open-with-name',
defaultMessage: 'Open {name}',
},
loading: { id: 'app.library.instance.loading', defaultMessage: 'Instance is loading...' },
installing: { id: 'app.library.instance.installing', defaultMessage: 'Installing...' },
stop: { id: 'app.library.instance.stop', defaultMessage: 'Stop' },
repair: { id: 'app.library.instance.repair', defaultMessage: 'Repair' },
play: { id: 'app.library.instance.play', defaultMessage: 'Play' },
select: { id: 'app.library.instance.select', defaultMessage: 'Select instance' },
deselect: { id: 'app.library.instance.deselect', defaultMessage: 'Deselect instance' },
})
const {
displayState,
selectedLibraryInstances,
isLibraryInstanceSelectionActive,
activeDraggedInstanceKeys,
} = useLibrary()
const props = defineProps<{
instance: GameInstance
instanceGroupId: string
isSelectionAnchor?: boolean
}>()
const emit = defineEmits<{
(e: 'toggle-selection', shiftKey: boolean): void
}>()
const instanceCardElement = ref<InstanceType<typeof InstanceCardView> | null>(null)
const playing = ref(false)
const loading = ref(false)
const currentEvent = ref<ProcessEvent | null>(null)
const isPrimaryPointerDown = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const loadingIndicatorRequested = computed(
() => !playing.value && (modLoading.value || installing.value),
)
const loadingIndicatorVisible = ref(false)
watch(
loadingIndicatorRequested,
(requested, _, onCleanup) => {
loadingIndicatorVisible.value = false
if (!requested) return
const timeout = setTimeout(() => {
loadingIndicatorVisible.value = true
}, LOADING_INDICATOR_DELAY_MS)
onCleanup(() => clearTimeout(timeout))
},
{ immediate: true },
)
const selectionKey = computed(() =>
getLibraryInstanceSelectionKey({
instanceId: props.instance.id,
groupId: props.instanceGroupId,
}),
)
const selected = computed(() => selectedLibraryInstances.value.has(selectionKey.value))
const keys = useMagicKeys()
const holdingShift = computed(() => keys.shift.value)
const isPartOfActiveDrag = computed(() => activeDraggedInstanceKeys.value.has(selectionKey.value))
const { isDragging } = useDraggable({
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
element: instanceCardElement,
disabled: computed(() => displayState.value.group !== 'Group'),
sensors: instanceCardSensors,
data: computed(() => ({
instanceId: props.instance.id,
fromGroup: props.instanceGroupId,
})),
})
const router = useRouter()
const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
}
const toggleSelection = (event?: MouseEvent) => {
emit('toggle-selection', event?.shiftKey ?? false)
}
const handlePointerDown = (event: PointerEvent) => {
isPrimaryPointerDown.value =
event.button === 0 &&
!(event.target instanceof Element && event.target.closest('.selection-button'))
}
const resetPrimaryPointer = () => {
isPrimaryPointerDown.value = false
}
useEventListener(window, 'pointerup', resetPrimaryPointer)
useEventListener(window, 'pointercancel', resetPrimaryPointer)
useEventListener(window, 'blur', resetPrimaryPointer)
const activateCard = (event: MouseEvent) => {
if (isLibraryInstanceSelectionActive.value || event.shiftKey) {
toggleSelection(event)
} else {
void seeInstance()
}
}
const handleCardKeydown = (event: KeyboardEvent) => {
if (event.target !== event.currentTarget) return
if (event.key === 'Enter') {
event.preventDefault()
if (isLibraryInstanceSelectionActive.value) {
toggleSelection()
} else {
void seeInstance()
}
} else if (event.key === ' ' && isLibraryInstanceSelectionActive.value) {
event.preventDefault()
toggleSelection()
}
}
const checkProcess = async () => {
const runningProcesses = (await get_by_instance_id(props.instance.id).catch(handleError)) ?? []
playing.value = runningProcesses.length > 0
}
const play = async (event: MouseEvent | null, context: string) => {
event?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
})
loading.value = false
}
const stop = async (event: MouseEvent | null, context: string) => {
event?.stopPropagation()
playing.value = false
await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
}
const repair = async (event: MouseEvent) => {
event.stopPropagation()
if (props.instance.quarantined) return
if (
props.instance.install_stage !== 'pack_installed' &&
(props.instance.link?.type === 'modrinth_modpack' ||
props.instance.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(props.instance.id, {
type: 'fromVersionId',
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
title: props.instance.name,
}).catch(handleError)
} else {
await install_existing_instance(props.instance.id, false).catch(handleError)
}
}
const openFolder = async () => {
await showInstanceInFolder(props.instance.id)
}
const addContent = async () => {
if (props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.id },
})
}
defineExpose({
play,
stop,
seeInstance,
openFolder,
addContent,
instance: props.instance,
})
useAppEvent('process', (event) => {
if (event.instance_id === props.instance.id) {
currentEvent.value = event.event
playing.value = event.event === 'launched'
}
})
onMounted(() => {
void checkProcess()
})
</script>
<template>
<InstanceCardView
ref="instanceCardElement"
class="group/card cursor-pointer -outline-offset-2 focus-visible:!outline-2 hover:brightness-110"
:class="{
'!scale-100': isDragging,
'opacity-50': isPartOfActiveDrag,
'scale-[0.95]': isPrimaryPointerDown && !isLibraryInstanceSelectionActive,
}"
:instance="instance"
:selected="selected"
data-library-instance-card
:data-instance-id="instance.id"
:data-instance-group="instanceGroupId"
role="button"
tabindex="0"
:aria-label="
isLibraryInstanceSelectionActive
? formatMessage(selected ? messages.deselectInstance : messages.selectInstance, {
name: instance.name,
})
: formatMessage(messages.openInstance, { name: instance.name })
"
:aria-pressed="isLibraryInstanceSelectionActive ? selected : undefined"
@click="activateCard"
@keydown="handleCardKeydown"
@mouseenter="checkProcess"
@pointerdown="handlePointerDown"
>
<template #loading>
<div
v-if="loadingIndicatorVisible"
class="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center"
>
<div class="absolute inset-0 bg-surface-1 opacity-30" />
<SpinnerIcon
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
class="relative size-[30%] animate-spin text-contrast"
tabindex="-1"
/>
</div>
</template>
<template #leading>
<div class="relative flex size-12 shrink-0 items-center justify-center">
<div class="absolute inset-0 flex items-center justify-center">
<IconButton
v-if="playing"
v-tooltip="formatMessage(messages.stop)"
:label="formatMessage(messages.stop)"
type="colored"
color="red"
size="lg"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<StopCircleIcon />
</IconButton>
<IconButton
v-else-if="
!modLoading &&
!installing &&
!isLibraryInstanceSelectionActive &&
!installed &&
!instance.quarantined
"
v-tooltip="formatMessage(messages.repair)"
:label="formatMessage(messages.repair)"
type="colored"
color="brand"
size="lg"
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
@click="(e) => repair(e)"
>
<DownloadIcon />
</IconButton>
<IconButton
v-else-if="
!modLoading &&
!installing &&
!isLibraryInstanceSelectionActive &&
!instance.quarantined
"
v-tooltip="formatMessage(messages.play)"
:label="formatMessage(messages.play)"
type="colored"
color="brand"
size="lg"
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<PlayIcon class="translate-x-px" />
</IconButton>
</div>
</div>
</template>
<template #overlay>
<button
type="button"
class="selection-button group/selection absolute right-2 top-1.5 z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
:aria-label="formatMessage(selected ? messages.deselect : messages.select)"
:aria-pressed="selected"
@click.stop="toggleSelection"
>
<span
v-tooltip="formatMessage(selected ? messages.deselect : messages.select)"
class="relative flex size-[24px] items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
:class="{
'border-0 !opacity-100': selected,
'border-2 border-solid border-primary bg-transparent': !selected,
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
holdingShift && isSelectionAnchor,
}"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-contrast" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
</template>
</InstanceCardView>
</template>
@@ -0,0 +1,236 @@
<script setup lang="ts">
import {
DragDropProvider,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
DragOverlay,
type DragStartEvent,
} from '@dnd-kit/vue'
import { computed, nextTick, onBeforeUnmount, ref, toRef, watch } from 'vue'
import DragGather from '@/components/ui/library/instance-group/drag-gather.vue'
import DragPreview from '@/components/ui/library/instance-group/drag-preview.vue'
import { useInstanceDragGather } from '@/components/ui/library/instance-group/use-instance-drag-gather'
import { useLibrary } from '@/components/ui/library/use-library'
import type { GameInstance } from '@/helpers/types'
type InstanceDragData = {
instanceId: string
fromGroup: string
}
type InstanceGroupDndDropData = {
groupId: string
}
const props = defineProps<{
instances: GameInstance[]
}>()
const GROUP_HOVER_OPEN_DELAY = 750
const {
activeInstanceGroupDrag,
instanceGroupDragPointer,
instanceGroupDragStatus,
instanceGroupDragTarget,
isSectionCollapsed,
setSectionCollapsed,
startInstanceGroupDrag,
updateInstanceGroupDrag,
finishInstanceGroupDrag,
setInstanceGroupDragTarget,
getInstanceGroupDropState,
moveDraggedInstancesToGroup,
} = useLibrary()
const draggedInstances = computed(() => {
const drag = activeInstanceGroupDrag.value
if (!drag) return []
const draggedInstanceIds = new Set(drag.instances.map((selection) => selection.instanceId))
return props.instances.filter((instance) => draggedInstanceIds.has(instance.id))
})
const draggedInstance = computed(() => {
const drag = activeInstanceGroupDrag.value
return drag
? props.instances.find((instance) => instance.id === drag.primaryInstanceId)
: undefined
})
const {
items: gatherItems,
target: gatherTarget,
isGathering,
start: startGather,
updateTarget: updateGatherTarget,
clear: clearGather,
finish: finishGather,
} = useInstanceDragGather(toRef(props, 'instances'))
const instanceGroupDragStatusPopover = ref<HTMLElement>()
let statusPopoverFrame: number | undefined
let groupHoverOpenTimeout: ReturnType<typeof setTimeout> | undefined
const instanceGroupDragStatusStyle = computed(() => ({
left: `${Math.min(instanceGroupDragPointer.value.x + 4, window.innerWidth - 220)}px`,
top: `${Math.min(instanceGroupDragPointer.value.y + 4, window.innerHeight - 48)}px`,
}))
const hoveredCollapsedGroupId = computed(() => {
const target = instanceGroupDragTarget.value
if (
!activeInstanceGroupDrag.value ||
!target ||
!isSectionCollapsed(target) ||
!getInstanceGroupDropState(target).canDrop
) {
return null
}
return target
})
watch(
activeInstanceGroupDrag,
(drag) => {
if (statusPopoverFrame !== undefined) {
cancelAnimationFrame(statusPopoverFrame)
statusPopoverFrame = undefined
}
const popover = instanceGroupDragStatusPopover.value
if (!drag) {
if (popover?.matches(':popover-open')) {
popover.hidePopover()
}
return
}
statusPopoverFrame = requestAnimationFrame(() => {
statusPopoverFrame = undefined
const currentPopover = instanceGroupDragStatusPopover.value
if (activeInstanceGroupDrag.value && !currentPopover?.matches(':popover-open')) {
currentPopover?.showPopover()
}
})
},
{ flush: 'sync' },
)
function clearGroupHoverOpenTimeout() {
if (groupHoverOpenTimeout !== undefined) {
clearTimeout(groupHoverOpenTimeout)
}
groupHoverOpenTimeout = undefined
}
watch(hoveredCollapsedGroupId, (target) => {
clearGroupHoverOpenTimeout()
if (!target) return
groupHoverOpenTimeout = setTimeout(() => {
groupHoverOpenTimeout = undefined
if (hoveredCollapsedGroupId.value === target) {
setSectionCollapsed(target, false)
}
}, GROUP_HOVER_OPEN_DELAY)
})
onBeforeUnmount(() => {
if (statusPopoverFrame !== undefined) {
cancelAnimationFrame(statusPopoverFrame)
}
clearGroupHoverOpenTimeout()
})
function isAltKeyPressed(event?: Event) {
return event instanceof MouseEvent || event instanceof KeyboardEvent ? event.altKey : false
}
function handleDragStart(event: DragStartEvent) {
const sourceData = event.operation.source?.data as InstanceDragData | undefined
if (!sourceData) return
const pointer = event.operation.position.current
startInstanceGroupDrag(
sourceData.instanceId,
sourceData.fromGroup,
pointer,
isAltKeyPressed(event.nativeEvent),
)
startGather(activeInstanceGroupDrag.value, sourceData, pointer)
}
function handleDragMove(event: DragMoveEvent) {
const pointer = event.to ?? event.operation.position.current
updateInstanceGroupDrag(pointer, isAltKeyPressed(event.nativeEvent))
if (gatherItems.value.length > 0) {
updateGatherTarget(pointer)
}
}
function handleDragOver(event: DragOverEvent) {
const targetData = event.operation.target?.data as InstanceGroupDndDropData | undefined
setInstanceGroupDragTarget(targetData?.groupId ?? null)
}
async function handleDragEnd(event: DragEndEvent) {
clearGroupHoverOpenTimeout()
const targetData = event.operation.target?.data as InstanceGroupDndDropData | undefined
let isMovingInstances = false
if (!event.canceled && targetData) {
const dropState = getInstanceGroupDropState(targetData.groupId)
if (dropState.canDrop) {
void moveDraggedInstancesToGroup(targetData.groupId, dropState.operation === 'add')
isMovingInstances = true
}
}
if (isMovingInstances) {
await nextTick()
}
clearGather()
finishInstanceGroupDrag()
}
</script>
<template>
<DragDropProvider
@drag-start="handleDragStart"
@drag-move="handleDragMove"
@drag-over="handleDragOver"
@drag-end="handleDragEnd"
>
<slot />
<Teleport to="body">
<div class="pointer-events-none fixed inset-0 z-[9999]">
<DragOverlay :drop-animation="null">
<div
v-if="draggedInstance"
class="w-full transition-all duration-150 ease-out"
:class="isGathering ? 'scale-[0.975]' : 'scale-100'"
>
<DragPreview :instance="draggedInstance" :count="draggedInstances.length" />
</div>
</DragOverlay>
</div>
</Teleport>
</DragDropProvider>
<DragGather
v-if="gatherItems.length > 0"
:items="gatherItems"
:target="gatherTarget"
@complete="finishGather"
/>
<Teleport to="body">
<div
ref="instanceGroupDragStatusPopover"
popover="manual"
class="pointer-events-none fixed inset-auto z-[9999] m-0 rounded-xl border border-solid border-surface-5 bg-surface-4 px-2 py-1.5 text-sm font-semibold text-contrast empty:hidden"
:style="instanceGroupDragStatusStyle"
>
{{ instanceGroupDragStatus }}
</div>
</Teleport>
</template>
@@ -0,0 +1,130 @@
import { onBeforeUnmount, type Ref, ref } from 'vue'
import type { ActiveInstanceGroupDrag } from '@/components/ui/library/use-library'
import type { GameInstance } from '@/helpers/types'
type Point = {
x: number
y: number
}
type InstanceDragData = {
instanceId: string
fromGroup: string
}
export const gatherDuration = 500
export type InstanceDragGatherItem = {
instance: GameInstance
rect: {
left: number
top: number
width: number
height: number
}
}
export function useInstanceDragGather(instances: Ref<GameInstance[]>) {
const items = ref<InstanceDragGatherItem[]>([])
const target = ref<Point>({ x: 0, y: 0 })
const targetOffset = ref<Point>({ x: 0, y: 0 })
const isGathering = ref(false)
let cleanupTimer: number | undefined
const clearCleanupTimer = () => {
if (cleanupTimer !== undefined) {
clearTimeout(cleanupTimer)
cleanupTimer = undefined
}
}
const clear = () => {
clearCleanupTimer()
items.value = []
targetOffset.value = { x: 0, y: 0 }
isGathering.value = false
}
const updateTarget = (pointer: Point) => {
target.value = {
x: pointer.x + targetOffset.value.x,
y: pointer.y + targetOffset.value.y,
}
}
const start = (
drag: ActiveInstanceGroupDrag | null,
source: InstanceDragData,
pointer: Point,
) => {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.instances.length < 2 || reduceMotion) return
const instanceCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-library-instance-card]'),
)
const sourceCard = instanceCards.find(
(card) =>
card.dataset.instanceId === source.instanceId &&
card.dataset.instanceGroup === source.fromGroup,
)
if (sourceCard) {
const sourceRect = sourceCard.getBoundingClientRect()
targetOffset.value = {
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
}
}
items.value = drag.instances.flatMap(({ instanceId, groupId }) => {
const instance = instances.value.find((candidate) => candidate.id === instanceId)
const matchingCards = instanceCards.filter(
(card) => card.dataset.instanceId === instanceId && card.getClientRects().length > 0,
)
const card =
matchingCards.find((candidate) => candidate.dataset.instanceGroup === groupId) ??
matchingCards[0]
if (!instance || !card) return []
const rect = card.getBoundingClientRect()
return [
{
instance,
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
},
]
})
updateTarget(pointer)
isGathering.value = items.value.length > 0
}
const finish = () => {
isGathering.value = false
clearCleanupTimer()
cleanupTimer = window.setTimeout(() => {
items.value = []
cleanupTimer = undefined
}, 400)
}
onBeforeUnmount(clearCleanupTimer)
return {
items,
target,
isGathering,
start,
updateTarget,
clear,
finish,
}
}
@@ -0,0 +1,70 @@
<script setup lang="ts">
import {
defineMessages,
DropdownFilterBar,
type DropdownFilterBarCategory,
formatLoader,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
const { filters, instances } = useLibrary()
const { formatMessage } = useVIntl()
const messages = defineMessages({
instanceType: { id: 'app.library.filter.instance-type', defaultMessage: 'Instance type' },
modpack: { id: 'app.library.filter.instance-type.modpack', defaultMessage: 'Modpack' },
server: { id: 'app.library.filter.instance-type.server', defaultMessage: 'Server' },
custom: { id: 'app.library.filter.instance-type.custom', defaultMessage: 'Custom' },
gameVersion: { id: 'app.library.filter.game-version', defaultMessage: 'Game version' },
loader: { id: 'app.library.filter.loader', defaultMessage: 'Loader' },
filterBy: { id: 'app.library.filter.label', defaultMessage: 'Filter by' },
addFilter: { id: 'app.library.filter.add', defaultMessage: 'Add filter' },
clearFilters: { id: 'app.library.filter.clear', defaultMessage: 'Clear filters' },
})
const filterCategories = computed<DropdownFilterBarCategory[]>(() => [
{
key: 'instanceType',
label: formatMessage(messages.instanceType),
options: [
{ value: 'modpack', label: formatMessage(messages.modpack) },
{ value: 'server', label: formatMessage(messages.server) },
{ value: 'custom', label: formatMessage(messages.custom) },
],
},
{
key: 'gameVersion',
label: formatMessage(messages.gameVersion),
searchable: true,
options: [...new Set(instances.value.map((instance) => instance.game_version))]
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
.map((version) => ({ value: version, label: version })),
},
{
key: 'loader',
label: formatMessage(messages.loader),
options: [...new Set(instances.value.map((instance) => instance.loader))]
.map((loader) => ({
value: loader,
label: formatLoader(formatMessage, loader),
}))
.sort((a, b) => a.label.localeCompare(b.label)),
},
])
</script>
<template>
<DropdownFilterBar
v-model="filters"
:categories="filterCategories"
use-filter-icon
:label="formatMessage(messages.filterBy)"
:add-label="formatMessage(messages.addFilter)"
:clear-label="formatMessage(messages.clearFilters)"
apply-immediately
checkbox-position="right"
/>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { PlusIcon, SearchIcon, SquarePlusIcon } from '@modrinth/assets'
import { Button, defineMessages, StyledInput, useVIntl } from '@modrinth/ui'
import { computed, inject } from 'vue'
import FilterMenu from '@/components/ui/library/library-toolbar/filter-menu.vue'
import NewGroupModal from '@/components/ui/library/library-toolbar/new-group-modal.vue'
import SortMenu from '@/components/ui/library/library-toolbar/sort-menu.vue'
import { useLibrary } from '@/components/ui/library/use-library'
const { search, selectedLibraryInstances, openNewGroupModal } = useLibrary()
const showCreationModal = inject<() => void>('showCreationModal')
const { formatMessage } = useVIntl()
const messages = defineMessages({
search: { id: 'app.library.search.placeholder', defaultMessage: 'Search' },
newGroup: { id: 'app.library.group.new', defaultMessage: 'New group' },
newInstance: { id: 'app.library.instance.new', defaultMessage: 'New instance' },
})
const selectedInstanceIds = computed(
() =>
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
)
function openNewGroup() {
openNewGroupModal(selectedInstanceIds.value)
}
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[16rem] flex-1"
/>
<Button @click="openNewGroup">
<SquarePlusIcon />
{{ formatMessage(messages.newGroup) }}
</Button>
<Button type="colored" color="brand" @click="showCreationModal?.()">
<PlusIcon />
{{ formatMessage(messages.newInstance) }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<SortMenu />
<div class="mx-2 h-6 w-px bg-surface-5" />
<FilterMenu />
</div>
</div>
<NewGroupModal />
</template>
@@ -0,0 +1,177 @@
<template>
<NewModal
ref="modal"
no-padding
scrollable
actions-divider
max-width="560px"
width="560px"
:on-hide="closeNewGroupModal"
>
<template #title>
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(messages.createGroup) }}
</span>
</template>
<div class="flex flex-col gap-2.5 p-6">
<label for="new-group-name" class="font-semibold text-contrast">
{{ formatMessage(messages.groupName) }}
</label>
<StyledInput
id="new-group-name"
ref="groupNameInput"
v-model="newGroupName"
:placeholder="formatMessage(messages.groupNamePlaceholder)"
:maxlength="MAX_INSTANCE_GROUP_NAME_LENGTH"
@click="groupNameInput?.select()"
/>
</div>
<div class="h-px bg-divider" />
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
<div class="px-6">
<StyledInput
v-model="newGroupSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchInstance)"
class="w-full"
/>
</div>
<div
v-if="newGroupInstances.length === 0"
class="flex items-center justify-center py-12 text-secondary"
>
{{ formatMessage(messages.noInstancesFound) }}
</div>
<div v-else class="flex flex-col gap-1">
<div
v-for="instance in newGroupInstances"
:key="instance.id"
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
:class="{ 'opacity-60': selectedNewGroupInstanceIds.has(instance.id) }"
>
<div class="flex min-w-0 items-center gap-2.5">
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
:alt="instance.name"
size="2rem"
rounded="md"
/>
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
<TagItem v-if="instance.group_ids[0]" class="shrink-0">
{{
groupNamesById.get(instance.group_ids[0]) ?? formatMessage(messages.unknownGroup)
}}
</TagItem>
</div>
</div>
<Button
:type="selectedNewGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
@click="toggleNewGroupInstance(instance.id)"
>
<CheckIcon v-if="selectedNewGroupInstanceIds.has(instance.id)" />
{{
formatMessage(
selectedNewGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
)
}}
</Button>
</div>
</div>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(messages.cancel) }}
</Button>
<Button type="colored" color="brand" :disabled="!canCreateGroup" @click="handleCreateGroup">
<SpinnerIcon v-if="creatingGroup" class="animate-spin" />
<PlusIcon v-else />
{{ formatMessage(messages.createGroup) }}
</Button>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
defineMessages,
NewModal,
StyledInput,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
import { getInstanceIconUrl } from '@/helpers/instance'
import { MAX_INSTANCE_GROUP_NAME_LENGTH } from '@/helpers/instance-groups'
const { formatMessage } = useVIntl()
const messages = defineMessages({
createGroup: { id: 'app.library.group.create', defaultMessage: 'Create group' },
groupName: { id: 'app.library.group.name', defaultMessage: 'Group name' },
groupNamePlaceholder: {
id: 'app.library.group.name-placeholder',
defaultMessage: 'Enter group name',
},
searchInstance: {
id: 'app.library.group.search-instance',
defaultMessage: 'Search instance',
},
noInstancesFound: {
id: 'app.library.group.no-instances-found',
defaultMessage: 'No instances found',
},
unknownGroup: { id: 'app.library.group.unknown', defaultMessage: 'Unknown group' },
add: { id: 'app.library.group.instance.add', defaultMessage: 'Add' },
added: { id: 'app.library.group.instance.added', defaultMessage: 'Added' },
cancel: { id: 'app.library.group.create.cancel', defaultMessage: 'Cancel' },
})
const {
isNewGroupModalOpen,
libraryGroups,
newGroupName,
newGroupSearch,
selectedNewGroupInstanceIds,
creatingGroup,
newGroupInstances,
canCreateGroup,
closeNewGroupModal,
toggleNewGroupInstance,
createGroup,
clearLibraryInstanceSelection,
} = useLibrary()
const modal = ref<InstanceType<typeof NewModal>>()
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
const groupNamesById = computed(
() => new Map(libraryGroups.value.map((group) => [group.id, group.name])),
)
watch(isNewGroupModalOpen, (open) => {
if (open) {
modal.value?.show()
}
})
async function handleCreateGroup() {
if (await createGroup()) {
clearLibraryInstanceSelection()
modal.value?.hide()
}
}
</script>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ArrowUpDownIcon, LayoutGridIcon } from '@modrinth/assets'
import { Combobox, type ComboboxOption, defineMessages, useVIntl } from '@modrinth/ui'
import {
type LibraryGroupBy,
libraryGroupOptions,
type LibrarySort,
librarySortOptions,
useLibrary,
} from '@/components/ui/library/use-library'
const { displayState } = useLibrary()
const { formatMessage } = useVIntl()
const messages = defineMessages({
name: { id: 'app.library.sort.name', defaultMessage: 'Name' },
lastPlayed: { id: 'app.library.sort.last-played', defaultMessage: 'Last played' },
hoursPlayed: { id: 'app.library.sort.hours-played', defaultMessage: 'Hours played' },
dateCreated: { id: 'app.library.sort.date-created', defaultMessage: 'Date created' },
dateModified: { id: 'app.library.sort.date-modified', defaultMessage: 'Date modified' },
customGroup: { id: 'app.library.group-by.custom-group', defaultMessage: 'Custom group' },
instanceType: { id: 'app.library.group-by.instance-type', defaultMessage: 'Instance type' },
loader: { id: 'app.library.group-by.loader', defaultMessage: 'Loader' },
gameVersion: { id: 'app.library.group-by.game-version', defaultMessage: 'Game version' },
noGrouping: { id: 'app.library.group-by.none', defaultMessage: 'No grouping' },
sortBy: { id: 'app.library.sort.label', defaultMessage: 'Sort by' },
groupBy: { id: 'app.library.group-by.label', defaultMessage: 'Group by' },
})
const sortLabels = {
Name: messages.name,
'Last played': messages.lastPlayed,
'Hours played': messages.hoursPlayed,
'Date created': messages.dateCreated,
'Date modified': messages.dateModified,
}
const groupLabels = {
Group: messages.customGroup,
'Instance type': messages.instanceType,
Loader: messages.loader,
'Game version': messages.gameVersion,
None: messages.noGrouping,
}
const sortOptions: ComboboxOption<LibrarySort>[] = librarySortOptions.map((option) => ({
value: option,
label: formatMessage(sortLabels[option]),
}))
const groupOptions: ComboboxOption<LibraryGroupBy>[] = libraryGroupOptions.map((option) => ({
value: option.value,
label: formatMessage(groupLabels[option.value]),
}))
</script>
<template>
<Combobox
v-model="displayState.sortBy"
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<ArrowUpDownIcon class="size-5 text-primary" :aria-label="formatMessage(messages.sortBy)" />
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
<Combobox
v-model="displayState.group"
class="w-max"
:options="groupOptions"
dropdown-min-width="160px"
:show-icon-in-selected="false"
>
<template #prefix>
<LayoutGridIcon class="size-5 text-primary" :aria-label="formatMessage(messages.groupBy)" />
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
</template>
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,72 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
<NewModal ref="modal" :header="modalHeader" fade="danger" max-width="500px">
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
{{ admonitionBody }}
</Admonition>
<div v-if="instances.length > 0" class="flex min-w-0 flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.instancesLabel, { count: instances.length }) }}
</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="instanceListRef"
class="flex max-h-[240px] flex-col gap-2 overflow-y-auto"
@scroll="checkScrollState"
>
<div
v-for="instance in instances"
:key="instance.id"
class="flex min-w-0 items-center gap-2.5 rounded-[20px] border border-solid border-transparent bg-surface-2 p-3"
>
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
size="40px"
class="!rounded-xl"
no-shadow
/>
<div class="flex min-w-0 flex-col gap-1.5">
<span class="min-w-0 truncate font-semibold text-contrast">
{{ instance.name }}
</span>
<span class="truncate text-sm font-medium text-secondary">
{{ formatLoader(formatMessage, instance.loader) }} {{ instance.game_version }}
</span>
</div>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<Button type="outlined" @click="modal?.hide()">
@@ -12,7 +75,7 @@
</Button>
<Button type="colored" color="red" @click="confirm">
<TrashIcon />
{{ formatMessage(messages.deleteButton) }}
{{ deleteButtonLabel }}
</Button>
</div>
</template>
@@ -23,13 +86,19 @@
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
Button,
commonMessages,
defineMessages,
formatLoader,
NewModal,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { getInstanceIconUrl } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl()
@@ -38,6 +107,10 @@ const messages = defineMessages({
id: 'app.instance.confirm-delete.header',
defaultMessage: 'Delete instance',
},
bulkHeader: {
id: 'app.instance.confirm-delete.bulk-header',
defaultMessage: 'Delete {count} instances',
},
admonitionHeader: {
id: 'app.instance.confirm-delete.admonition-header',
defaultMessage: 'This action cannot be undone',
@@ -47,20 +120,58 @@ const messages = defineMessages({
defaultMessage:
'All data for your instance will be permanently deleted, including your worlds, configs, and all installed content.',
},
bulkAdmonitionBody: {
id: 'app.instance.confirm-delete.bulk-admonition-body',
defaultMessage:
'All data for these {count} instances will be permanently deleted, including their worlds, configs, and all installed content.',
},
instancesLabel: {
id: 'app.instance.confirm-delete.instances-label',
defaultMessage: '{count, plural, one {Instance} other {Instances ({count})}}',
},
deleteButton: {
id: 'app.instance.confirm-delete.delete-button',
defaultMessage: 'Delete instance',
},
bulkDeleteButton: {
id: 'app.instance.confirm-delete.bulk-delete-button',
defaultMessage: 'Delete {count} instances',
},
})
const props = withDefaults(defineProps<{ instances?: GameInstance[] }>(), {
instances: () => [],
})
const instances = computed(() => props.instances)
const count = computed(() => instances.value.length || 1)
const emit = defineEmits<{
(e: 'delete'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const instanceListRef = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(instanceListRef)
const modalHeader = computed(() =>
count.value === 1
? formatMessage(messages.header)
: formatMessage(messages.bulkHeader, { count: count.value }),
)
const admonitionBody = computed(() =>
count.value === 1
? formatMessage(messages.admonitionBody)
: formatMessage(messages.bulkAdmonitionBody, { count: count.value }),
)
const deleteButtonLabel = computed(() =>
count.value === 1
? formatMessage(messages.deleteButton)
: formatMessage(messages.bulkDeleteButton, { count: count.value }),
)
function show() {
modal.value?.show()
nextTick(() => forceCheck())
}
function confirm() {
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { getInstanceIconUrl } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
defineProps<{
@@ -11,11 +11,7 @@ defineProps<{
</script>
<template>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px"
:tint-by="instance.id"
/>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" size="24px" :tint-by="instance.id" />
{{ instance.name }} <ChevronRightIcon />
</span>
</template>
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { BanIcon, SpinnerIcon, TagCategoryWandSparklesIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
defineMessages,
IconButton,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { nextTick, ref, useTemplateRef } from 'vue'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import { toError } from '@/helpers/errors'
import { list as listInstances } from '@/helpers/instance'
import icon01 from './assets/01.png'
import icon02 from './assets/02.png'
import icon03 from './assets/03.png'
import icon04 from './assets/04.png'
import icon05 from './assets/05.png'
import icon06 from './assets/06.png'
import icon07 from './assets/07.png'
import icon08 from './assets/08.png'
import icon09 from './assets/09.png'
const emit = defineEmits<{
close: []
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const iconEditorModal = useTemplateRef<InstanceType<typeof IconEditorModal>>('iconEditorModal')
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
const iconlessInstanceIds = ref<string[]>([])
const loading = ref(false)
const applying = ref(false)
const icons = [icon01, icon02, icon03, icon04, icon05, icon06, icon07, icon08, icon09]
const messages = defineMessages({
badge: {
id: 'app.icon-editor.apply-icons-modal.badge',
defaultMessage: 'New this update',
},
title: {
id: 'app.icon-editor.apply-icons-modal.title',
defaultMessage: 'Custom icon editor',
},
description: {
id: 'app.icon-editor.apply-icons-modal.description',
defaultMessage:
'Create custom icons for your instances right in the Modrinth App. Mix and match backgrounds with symbols from Minecraft and popular mods!',
},
instancesWithoutIcons: {
id: 'app.icon-editor.apply-icons-modal.instances-without-icons',
defaultMessage:
'{count, plural, one {# instance doesnt} other {# instances dont}} have icons yet. Want us to give {count, plural, one {it} other {them}} random ones to get started?',
},
skip: {
id: 'app.icon-editor.apply-icons-modal.skip',
defaultMessage: 'Skip',
},
randomIcons: {
id: 'app.icon-editor.apply-icons-modal.random-icons',
defaultMessage: 'Random icons',
},
close: {
id: 'app.icon-editor.apply-icons-modal.close',
defaultMessage: 'Close',
},
applyError: {
id: 'app.icon-editor.apply-icons-modal.apply-error',
defaultMessage: 'Failed to apply random icons to instances.',
},
applySuccess: {
id: 'app.icon-editor.apply-icons-modal.apply-success',
defaultMessage: 'Randomized icon for {num, plural, one {# instance} other {# instances}}.',
},
})
async function show() {
modal.value?.show()
loading.value = true
try {
iconlessInstanceIds.value = (await listInstances())
.filter((instance) => !instance.icon_path)
.map((instance) => instance.id)
} catch (error) {
handleError(toError(error))
iconlessInstanceIds.value = []
} finally {
loading.value = false
}
}
function hide() {
modal.value?.hide()
}
function handleHide() {
emit('close')
}
async function applyIcons() {
if (applying.value || loading.value) return
applying.value = true
const instanceCount = iconlessInstanceIds.value.length
let applied = false
try {
const results = await Promise.all(
iconlessInstanceIds.value.map(async (instanceId) => {
const generated = await iconEditorModal.value?.randomize()
if (!generated) return false
return (
(await iconEditorModal.value?.applyGeneratedIcon(instanceId, generated.config)) ?? false
)
}),
)
applied = results.every(Boolean)
} finally {
applying.value = false
}
if (applied) {
await nextTick()
hide()
addNotification({
type: 'success',
title: formatMessage(messages.applySuccess, { num: instanceCount }),
})
} else {
addNotification({
type: 'error',
title: formatMessage(messages.applyError),
})
}
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
hide-header
no-padding
width="770px"
max-width="calc(100vw - 2rem)"
:aria-label="formatMessage(messages.title)"
:on-after-hide="handleHide"
:disable-close="applying"
class="!overflow-hidden !rounded-[20px]"
>
<div class="grid h-[384px] w-[768px] max-w-full grid-cols-2">
<section class="flex min-w-0 flex-col gap-6 bg-surface-3 p-8">
<div
class="flex h-8 w-fit items-center rounded-full border border-solid border-brand bg-brand-highlight px-2.5 text-sm font-base leading-5 text-brand"
>
{{ formatMessage(messages.badge) }}
</div>
<div class="flex min-w-0 flex-col gap-4">
<h2 class="m-0 text-2xl font-semibold leading-6 text-contrast">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 leading-6 text-primary">
{{ formatMessage(messages.description) }}
</p>
<p v-if="iconlessInstanceIds.length" class="m-0 leading-6 text-primary">
{{
formatMessage(messages.instancesWithoutIcons, {
count: iconlessInstanceIds.length,
})
}}
</p>
</div>
<div class="flex mt-auto items-center gap-2.5">
<Button size="lg" :disabled="applying" @click="hide">
<template v-if="iconlessInstanceIds.length === 0">
{{ formatMessage(messages.close) }}
</template>
<template v-else>
<BanIcon />
{{ formatMessage(messages.skip) }}
</template>
</Button>
<Button
v-if="iconlessInstanceIds.length !== 0"
type="colored"
color="brand"
size="lg"
:disabled="loading || applying"
@click="applyIcons"
>
<SpinnerIcon v-if="applying" class="animate-spin" />
<TagCategoryWandSparklesIcon v-else />
{{ formatMessage(messages.randomIcons) }}
</Button>
</div>
</section>
<section
class="relative flex min-w-0 items-center justify-center border-0 border-l border-solid border-surface-5 bg-surface-2 p-10"
>
<IconButton
type="quiet"
size="sm"
:label="formatMessage(messages.close)"
class="!absolute right-6 top-6 z-10"
:disabled="applying"
@click="hide"
>
<XIcon />
</IconButton>
<div class="grid size-60 grid-cols-3 grid-rows-3 gap-4" aria-hidden="true">
<Avatar
v-for="(icon, index) in icons"
:key="index"
:src="icon"
size="100%"
class="min-h-0 min-w-0 !rounded-2xl"
/>
</div>
</section>
</div>
</NewModal>
<IconEditorModal ref="iconEditorModal" />
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { Button, defineMessages, IconButton, useVIntl } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import ApplyNewIconsModal from './apply-new-icons-modal.vue'
import icon01 from './assets/01.png'
import icon02 from './assets/02.png'
import icon03 from './assets/03.png'
import icon04 from './assets/04.png'
import icon05 from './assets/05.png'
import icon06 from './assets/06.png'
import icon07 from './assets/07.png'
import icon08 from './assets/08.png'
import icon09 from './assets/09.png'
const emit = defineEmits<{
dismiss: []
}>()
const { formatMessage } = useVIntl()
const applyNewIconsModal =
useTemplateRef<InstanceType<typeof ApplyNewIconsModal>>('applyNewIconsModal')
const modalOpen = ref(false)
const icons = [icon01, icon02, icon03, icon04, icon05, icon06, icon07, icon08, icon09]
const messages = defineMessages({
title: {
id: 'app.icon-editor.notification.title',
defaultMessage: 'New icon editor',
},
description: {
id: 'app.icon-editor.notification.description',
defaultMessage: 'Personalize your instances!',
},
viewUpdate: {
id: 'app.icon-editor.notification.view-update',
defaultMessage: 'View update',
},
dismiss: {
id: 'app.icon-editor.notification.dismiss',
defaultMessage: 'Dismiss',
},
close: {
id: 'app.icon-editor.notification.close',
defaultMessage: 'Close',
},
})
function viewUpdate() {
modalOpen.value = true
applyNewIconsModal.value?.show()
}
function handleModalClose() {
modalOpen.value = false
emit('dismiss')
}
</script>
<template>
<section
v-show="!modalOpen"
class="relative ml-auto w-full max-w-full overflow-hidden rounded-2xl border border-solid border-surface-5 bg-surface-3 p-4 shadow-xl"
:aria-label="formatMessage(messages.title)"
>
<div class="icon-grid" aria-hidden="true">
<div v-for="(icon, index) in icons" :key="index" class="icon-tile">
<img :src="icon" alt="" />
</div>
</div>
<div class="icon-fade" />
<div class="relative z-[1] flex w-full flex-col items-start gap-3">
<div class="flex w-full flex-col gap-2">
<div class="flex w-full items-center justify-between gap-2.5">
<div class="min-w-0 flex-1 font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</div>
<IconButton
class="-m-1.5 -mx-2"
type="quiet"
size="sm"
:label="formatMessage(messages.close)"
@click="emit('dismiss')"
>
<XIcon />
</IconButton>
</div>
<div class="text-primary">
{{ formatMessage(messages.description) }}
</div>
</div>
<div class="flex items-center gap-1.5">
<Button type="colored" color="brand" @click="viewUpdate">
{{ formatMessage(messages.viewUpdate) }}
</Button>
<Button @click="emit('dismiss')">
{{ formatMessage(messages.dismiss) }}
</Button>
</div>
</div>
</section>
<ApplyNewIconsModal ref="applyNewIconsModal" @close="handleModalClose" />
</template>
<style scoped>
.icon-grid {
position: absolute;
top: -4px;
left: 232px;
display: grid;
width: 160px;
height: 160px;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 8px;
transform: rotate(23.89deg);
}
.icon-tile {
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.12);
}
.icon-tile img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.icon-fade {
position: absolute;
inset: 0 auto 0 210px;
width: 110px;
background: linear-gradient(90deg, var(--surface-3) 0%, transparent 100%);
}
</style>
@@ -0,0 +1,12 @@
const STORAGE_KEY = 'new-icon-editor-notification-shown'
export function shouldShowNewIconEditorNotification(showOnboardingChecklist: boolean): boolean {
try {
if (localStorage.getItem(STORAGE_KEY) === 'true') return false
if (showOnboardingChecklist) return false
localStorage.setItem(STORAGE_KEY, 'true')
return true
} catch {
return !showOnboardingChecklist
}
}
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { CheckIcon, RadioButtonIcon } from '@modrinth/assets'
import { Accordion, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { injectOnboardingChecklist } from '@/providers/onboarding-checklist'
const emit = defineEmits<{
'create-instance': []
'login-minecraft': []
'login-modrinth': []
}>()
const { formatMessage } = useVIntl()
const {
hasCreatedInstance,
hasLoggedIntoMinecraft,
hasLoggedIntoModrinth,
isReady,
showChecklist,
} = injectOnboardingChecklist()
const collapsedCornersVisible = ref(false)
let collapseTimer: ReturnType<typeof setTimeout> | undefined
const messages = defineMessages({
title: {
id: 'onboarding-checklist.title',
defaultMessage: 'Getting started',
},
createInstance: {
id: 'onboarding-checklist.create-instance',
defaultMessage: 'Create first instance',
},
loginMinecraft: {
id: 'onboarding-checklist.login-minecraft',
defaultMessage: 'Sign in to Minecraft',
},
loginModrinth: {
id: 'onboarding-checklist.login-modrinth',
defaultMessage: 'Sign in to Modrinth',
},
})
const steps = computed(() => [
{
id: 'create-instance',
label: formatMessage(messages.createInstance),
complete: hasCreatedInstance.value,
action: () => emit('create-instance'),
},
{
id: 'login-minecraft',
label: formatMessage(messages.loginMinecraft),
complete: hasLoggedIntoMinecraft.value,
action: () => emit('login-minecraft'),
},
{
id: 'login-modrinth',
label: formatMessage(messages.loginModrinth),
complete: hasLoggedIntoModrinth.value,
action: () => emit('login-modrinth'),
},
])
const accordionButtonClass = computed(
() =>
`flex w-full cursor-pointer items-center justify-between rounded-t-2xl border border-solid border-button-border bg-button-bg p-3 text-left text-contrast transition-[filter] hover:brightness-110${collapsedCornersVisible.value ? ' rounded-b-2xl' : ''}`,
)
function handleAccordionOpen() {
clearTimeout(collapseTimer)
collapsedCornersVisible.value = false
}
function handleAccordionClose() {
clearTimeout(collapseTimer)
collapseTimer = setTimeout(() => {
collapsedCornersVisible.value = true
}, 300)
}
onUnmounted(() => clearTimeout(collapseTimer))
</script>
<template>
<div
v-if="isReady && showChecklist"
class="border-0 border-b-[1px] border-solid border-[--brand-gradient-border] px-3 p-4"
>
<Accordion
open-by-default
class="w-full overflow-hidden rounded-2xl text-base"
:button-class="accordionButtonClass"
content-class="flex flex-col gap-2 rounded-b-2xl border border-t-0 border-solid border-surface-5 bg-button-bg p-3"
@on-open="handleAccordionOpen"
@on-close="handleAccordionClose"
>
<template #title>
<span class="font-semibold leading-6">{{ formatMessage(messages.title) }}</span>
</template>
<button
v-for="step in steps"
:key="step.id"
type="button"
class="flex h-10 w-full items-center gap-2 rounded-xl border border-solid border-button-border bg-button-bg px-4 py-2.5 text-left text-primary shadow-[0_1px_0.5px_rgb(0_0_0_/_12%)] transition-[filter]"
:class="
step.complete
? '!cursor-default opacity-50'
: 'cursor-pointer hover:brightness-110 active:brightness-90'
"
:disabled="step.complete"
@click="step.action"
>
<span
v-if="step.complete"
class="flex size-[18px] items-center justify-center rounded-full bg-primary mr-0.5 relative left-px"
>
<CheckIcon class="size-3 invert [stroke-width:3] top-px" />
</span>
<RadioButtonIcon v-else class="size-5 shrink-0" />
<span
class="min-w-0 truncate font-medium leading-5"
:class="{ 'text-secondary line-through': step.complete }"
>
{{ step.label }}
</span>
</button>
</Accordion>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Chips, defineMessages, Toggle, useVIntl } from '@modrinth/ui'
import { defineMessages, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
@@ -14,9 +14,6 @@ const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
type LandingPage = 'Home' | 'Library'
const landingPageOptions: LandingPage[] = ['Home', 'Library']
const messages = defineMessages({
startupAndNavigationTitle: {
id: 'app.behavior-settings.startup-and-navigation.title',
@@ -38,14 +35,6 @@ const messages = defineMessages({
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize Modrinth App when Minecraft starts.',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Choose the page shown when Modrinth App opens.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
@@ -64,11 +53,12 @@ const messages = defineMessages({
},
jumpBackIntoWorldsTitle: {
id: 'app.appearance-settings.jump-back-into-worlds.title',
defaultMessage: 'Jump back into worlds',
defaultMessage: 'Jump into worlds or instances',
},
jumpBackIntoWorldsDescription: {
id: 'app.appearance-settings.jump-back-into-worlds.description',
defaultMessage: 'Show recently played worlds in the "Jump back in" section on the Home page.',
defaultMessage:
'Show recently played worlds or instances in the "Jump in" section on the Home page.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
@@ -106,15 +96,6 @@ const messages = defineMessages({
},
})
function formatLandingPageLabel(page: LandingPage) {
switch (page) {
case 'Home':
return formatMessage(messages.defaultLandingPageHome)
case 'Library':
return formatMessage(messages.defaultLandingPageLibrary)
}
}
const settings = ref(await get())
watch(
@@ -131,22 +112,6 @@ watch(
{{ formatMessage(messages.startupAndNavigationTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</h3>
<Chips
v-model="settings.default_page"
:items="landingPageOptions"
:format-label="formatLandingPageLabel"
:capitalize="false"
:aria-label="formatMessage(messages.defaultLandingPageTitle)"
/>
<p class="m-0">
{{ formatMessage(messages.defaultLandingPageDescription) }}
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
@@ -225,14 +225,14 @@ export function useSharedInstanceInviteHandler(
displayedNotificationKeys.add(notificationKey)
const popupNotification = popupNotificationManager.addPopupNotification({
contentType: 'toast',
title: invite.sharedInstanceName,
autoCloseMs: null,
toast: {
type: 'instance-invite',
actorName: invite.invitedByUsername,
actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
entityName: invite.sharedInstanceName,
entityIconUrl: invite.instanceIconUrl ?? undefined,
autoCloseMs: null,
onAccept: () => acceptNotification(notification, invite),
onDecline: () =>
markNotificationRead(notification).catch((error) => handleError(toError(error))),
@@ -241,7 +241,6 @@ export function useSharedInstanceInviteHandler(
void router.push(`/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
})
popupNotificationIds.add(popupNotification.id)
return true
@@ -19,7 +19,7 @@
/>
</div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<div class="flex flex-col gap-4 w-full min-h-[20rem] min-w-52">
<section v-if="mode === 'edit' && canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
<Button @click="openTextureFileBrowser">
@@ -38,7 +38,7 @@
<h2 class="text-base font-semibold mb-2">
{{ formatMessage(messages.armStyleSection) }}
</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']" class="!flex-row flex-wrap">
<template #default="{ item }">
{{
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
@@ -218,6 +218,10 @@ const messages = defineMessages({
id: 'app.skins.modal.make-edit-first-tooltip',
defaultMessage: 'Make an edit to the skin first!',
},
demoSaveTooltip: {
id: 'app.skins.modal.demo-save-tooltip',
defaultMessage: 'Sign in to save skins.',
},
addSkinButton: {
id: 'app.skins.modal.add-skin-button',
defaultMessage: 'Add skin',
@@ -244,7 +248,7 @@ const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const props = defineProps<{ capes?: Cape[] }>()
const props = defineProps<{ capes?: Cape[]; demo?: boolean }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
@@ -322,12 +326,14 @@ const hasEdits = computed(() => {
const disableSave = computed(
() =>
props.demo ||
(mode.value === 'new' && !uploadedTextureUrl.value) ||
(mode.value === 'edit' && !hasEdits.value),
)
const saveTooltip = computed(() => {
if (isSaving.value) return formatMessage(messages.savingTooltip)
if (props.demo) return formatMessage(messages.demoSaveTooltip)
if (mode.value === 'new' && !uploadedTextureUrl.value) {
return formatMessage(messages.uploadSkinFirstTooltip)
}
@@ -426,6 +432,8 @@ async function onTextureFileInputChange(e: Event) {
}
async function save() {
if (props.demo) return
isSaving.value = true
try {
@@ -4,30 +4,32 @@ import {
FolderOpenIcon,
MoreVerticalIcon,
PlayIcon,
SparklesIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
Avatar,
BulletDivider,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
SmartClickable,
TagItem,
TeleportOverflowMenu,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Dayjs } from 'dayjs'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { get_project } from '@/helpers/cache'
import { kill, run } from '@/helpers/instance'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils'
@@ -50,18 +52,35 @@ const emit = defineEmits<{
const props = defineProps<{
instance: GameInstance
last_played: Dayjs
newlyAdded?: boolean
}>()
const loadingModpack = ref(!!props.instance.link)
const modpack = ref()
if (props.instance.link) {
nextTick().then(async () => {
modpack.value = await get_project(props.instance.link?.project_id, 'must_revalidate')
loadingModpack.value = false
})
}
const messages = defineMessages({
newInstance: {
id: 'app.home.jump-back-in.new-instance',
defaultMessage: 'New instance',
},
neverPlayed: {
id: 'app.home.jump-back-in.never-played',
defaultMessage: 'Never played',
},
lockedTooltip: {
id: 'app.home.jump-back-in.instance-locked',
defaultMessage: 'This instance has been locked',
},
alreadyOpenTooltip: {
id: 'app.home.jump-back-in.instance-open',
defaultMessage: 'Instance is already open',
},
moreOptions: {
id: 'app.home.jump-back-in.more-options',
defaultMessage: 'More options',
},
viewInstance: {
id: 'app.home.jump-back-in.view-instance',
defaultMessage: 'View instance',
},
})
const instanceIcon = computed(() => props.instance.icon_path)
@@ -82,8 +101,12 @@ const play = async (event: MouseEvent) => {
event?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
const launched = await run(props.instance.id)
.then(() => true)
.catch((err) => {
handleSevereError(err, { instanceId: props.instance.id })
return false
})
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
@@ -91,7 +114,7 @@ const play = async (event: MouseEvent) => {
source: 'InstanceItem',
})
})
emit('play')
if (launched) emit('play')
loading.value = false
}
@@ -123,7 +146,7 @@ onMounted(() => {
})
</script>
<template>
<SmartClickable>
<SmartClickable class="[--active-scale:0.99]">
<template #clickable>
<router-link
class="no-click-animation"
@@ -131,56 +154,53 @@ onMounted(() => {
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised card-shadow rounded-xl smart-clickable:highlight-on-hover"
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 border border-surface-4 rounded-[20px] smart-clickable:highlight-on-hover transition-[filter] ease-out [--hover-brightness:1.1] min-h-20 p-3"
:class="newlyAdded ? 'border-dashed bg-surface-2' : 'bg-bg-raised border-solid'"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
:src="getInstanceIconUrl(instanceIcon)"
:tint-by="instance.id"
no-shadow
class="!rounded-[14px]"
size="48px"
/>
<div class="flex flex-col col-span-2 justify-between h-full">
<div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
<div class="flex flex-col col-span-2 justify-center gap-1 h-full">
<div class="flex items-center gap-1.5">
<div class="text-contrast truncate text-base font-semibold">
{{ instance.name }}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="instance.last_played ? formatDateTime(instance.last_played) : null"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
<TagItem
v-if="newlyAdded"
class="!border-green !bg-bg-green !px-2 !font-medium !text-green"
>
<template v-if="last_played">
{{
formatMessage(commonMessages.playedLabel, {
ago: formatRelativeTime(last_played.toISOString?.()),
})
}}
</template>
<template v-else> Not played yet </template>
<SparklesIcon aria-hidden="true" />
{{ formatMessage(messages.newInstance) }}
</TagItem>
</div>
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
<router-link
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/project/${modpack.id}`"
>
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />
<span class="truncate">{{ modpack.title }}</span>
</router-link>
({{ loader }} {{ instance.game_version }})
</span>
<span v-else-if="loadingModpack" class="flex items-center gap-1 truncate text-secondary">
<SpinnerIcon class="animate-spin shrink-0" />
<span class="truncate">Loading modpack...</span>
</span>
<span v-else class="flex items-center gap-1 truncate text-secondary">
<div class="flex items-center gap-1.5 text-sm text-secondary">
<span class="flex items-center gap-1 truncate text-secondary">
{{ loader }}
{{ instance.game_version }}
</span>
<BulletDivider class="shrink-0" />
<div
v-tooltip="!newlyAdded ? formatDateTime(last_played.toDate()) : null"
class="w-fit shrink-0"
:class="{
'cursor-help smart-clickable:allow-pointer-events': !newlyAdded,
}"
>
<template v-if="newlyAdded">
{{ formatMessage(messages.neverPlayed) }}
</template>
<template v-else-if="last_played">
{{ formatRelativeTime(last_played.toISOString?.()) }}
</template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
</div>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
</div>
<div data-no-card-click class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<Button v-if="playing && !loading" type="colored" color="red" @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
@@ -189,12 +209,14 @@ onMounted(() => {
v-else
v-tooltip="
instance.quarantined
? 'This instance has been locked'
? formatMessage(messages.lockedTooltip)
: playing
? 'Instance is already open'
? formatMessage(messages.alreadyOpenTooltip)
: null
"
:disabled="instance.quarantined || playing || loading"
type="colored"
color="green"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
@@ -203,11 +225,11 @@ onMounted(() => {
</Button>
<TeleportOverflowMenu
type="quiet"
label="More options"
:label="formatMessage(messages.moreOptions)"
:options="[
{
id: 'open-instance',
label: 'View instance',
label: formatMessage(messages.viewInstance),
shown: !!instance.id,
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
},
@@ -221,7 +243,7 @@ onMounted(() => {
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
View instance
{{ formatMessage(messages.viewInstance) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
@@ -232,3 +254,8 @@ onMounted(() => {
</div>
</SmartClickable>
</template>
<style scoped>
.clickable-card:has([data-no-card-click]:hover) {
--hover-brightness: 1;
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { GAME_MODES, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
@@ -33,6 +33,10 @@ import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/theme.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
jumpIn: { id: 'app.home.jump-back-in.title', defaultMessage: 'Jump in' },
})
const props = defineProps<{
recentInstances: GameInstance[]
@@ -44,11 +48,11 @@ const jumpBackInItems = ref<JumpBackInItem[]>([])
const loading = ref(true)
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const locallyPlayedInstances = ref<Record<string, Dayjs>>({})
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
const MAX_JUMP_BACK_IN = 5
const MAX_NEW_INSTANCES = 3
const MAX_LINUX_POPULATES = 3
// Track populate calls on Linux to prevent server ping spam
@@ -56,8 +60,9 @@ const isLinux = platform() === 'linux'
const linuxPopulateCount = ref(0)
type BaseJumpBackInItem = {
last_played: Dayjs
sort_time: Dayjs
instance: GameInstance
newly_added?: boolean
}
type InstanceJumpBackInItem = BaseJumpBackInItem & {
@@ -108,7 +113,7 @@ async function populateJumpBackIn() {
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played ?? 0),
sort_time: dayjs(world.last_played ?? 0),
world: world,
instance: instance,
})
@@ -153,24 +158,44 @@ async function populateJumpBackIn() {
const instanceItems: InstanceJumpBackInItem[] = []
for (const instance of props.recentInstances) {
const worldItem = worldItems.find((item) => item.instance.id === instance.id)
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
continue
}
const lastPlayed = instance.last_played
? dayjs(instance.last_played)
: locallyPlayedInstances.value[instance.id]
const newlyAdded = !lastPlayed
if (worldItem && (!lastPlayed || !lastPlayed.isAfter(worldItem.sort_time))) continue
if (worldItem) worldItems.splice(worldItems.indexOf(worldItem), 1)
instanceItems.push({
type: 'instance',
last_played: dayjs(instance.last_played ?? 0),
sort_time: lastPlayed ?? dayjs(instance.created),
instance: instance,
newly_added: newlyAdded,
})
}
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
items.sort((a, b) => b.sort_time.diff(a.sort_time))
let newInstanceCount = 0
jumpBackInItems.value = items
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
.filter((item) => {
if (item.type !== 'instance' || !item.newly_added) return true
if (newInstanceCount >= MAX_NEW_INSTANCES) return false
newInstanceCount++
return true
})
.slice(0, MAX_JUMP_BACK_IN)
}
function markInstancePlayed(item: InstanceJumpBackInItem) {
const lastPlayed = dayjs()
locallyPlayedInstances.value = {
...locallyPlayedInstances.value,
[item.instance.id]: lastPlayed,
}
item.sort_time = lastPlayed
item.newly_added = false
}
function refreshServer(address: string, instanceId: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instanceId], address)
}
@@ -255,18 +280,22 @@ onMounted(() => {
<template>
<div v-if="loading" class="flex flex-col gap-2">
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
Jump back in
<span
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
>
{{ formatMessage(messages.jumpIn) }}
</span>
<div class="text-center py-4">
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
Jump back in
<span
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
>
{{ formatMessage(messages.jumpIn) }}
</span>
<div class="grid-when-huge flex flex-col w-full gap-2">
<div class="grid-when-huge flex flex-col w-full gap-3">
<template
v-for="item in jumpBackInItems"
:key="`${item.instance.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
@@ -327,7 +356,13 @@ onMounted(() => {
"
@stop="() => stopInstance(item.instance.id)"
/>
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
<InstanceItem
v-else
:instance="item.instance"
:last_played="item.sort_time"
:newly-added="item.newly_added"
@play="() => markInstancePlayed(item)"
/>
</template>
</div>
</div>
@@ -9,7 +9,6 @@ import {
MoreVerticalIcon,
NoSignalIcon,
PlayIcon,
SignalIcon,
SkullIcon,
SpinnerIcon,
StopCircleIcon,
@@ -21,26 +20,25 @@ import {
import type { MessageDescriptor } from '@modrinth/ui'
import {
Avatar,
BulletDivider,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
ServerOnlinePlayers,
SmartClickable,
TagItem,
TeleportOverflowMenu,
useFormatDateTime,
useFormatNumber,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { getPingLevel } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { getInstanceIconUrl } from '@/helpers/instance'
import { copyToClipboard, createInstanceShortcut } from '@/helpers/utils'
import type {
ProtocolVersion,
@@ -55,7 +53,6 @@ import { LockIcon } from '../../../../../../packages/assets/generated-icons'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatNumber = useFormatNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
@@ -124,9 +121,6 @@ const props = withDefaults(
)
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
const hasPlayersTooltip = computed(
() => !!props.serverStatus?.players?.sample && props.serverStatus.players?.sample?.length > 0,
)
const serverIncompatible = computed(
() =>
!!props.serverStatus &&
@@ -155,18 +149,31 @@ async function createShortcut() {
addNotification({
type: 'success',
title: 'Shortcut created',
title: formatMessage(messages.shortcutCreated),
})
} catch (error) {
addNotification({
type: 'error',
title: 'Failed to create shortcut',
title: formatMessage(messages.shortcutCreationFailed),
text: error instanceof Error ? error.message : '',
})
}
}
const messages = defineMessages({
shortcutCreated: {
id: 'instance.worlds.shortcut-created',
defaultMessage: 'Shortcut created',
},
shortcutCreationFailed: {
id: 'instance.worlds.shortcut-creation-failed',
defaultMessage: 'Failed to create shortcut',
},
moreOptions: { id: 'instance.worlds.more-options', defaultMessage: 'More options' },
instanceLocked: {
id: 'instance.worlds.instance-locked',
defaultMessage: 'This instance has been locked',
},
hardcore: {
id: 'instance.worlds.hardcore',
defaultMessage: 'Hardcore mode',
@@ -231,10 +238,6 @@ const messages = defineMessages({
id: 'app.world.world-item.incompatible-version',
defaultMessage: 'Incompatible version {version}',
},
playersOnline: {
id: 'app.world.world-item.players-online',
defaultMessage: '{count} online',
},
offline: {
id: 'app.world.world-item.offline',
defaultMessage: 'Offline',
@@ -246,7 +249,7 @@ const messages = defineMessages({
})
</script>
<template>
<SmartClickable>
<SmartClickable class="[--active-scale:0.985]">
<template v-if="instanceId" #clickable>
<router-link
class="no-click-animation"
@@ -254,7 +257,7 @@ const messages = defineMessages({
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised card-shadow smart-clickable:highlight-on-hover rounded-xl"
class="clickable-card grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised border border-solid border-surface-4 smart-clickable:highlight-on-hover rounded-[20px] transition-[filter] ease-out [--hover-brightness:1.25] min-h-20"
:class="{
'world-item-highlighted': highlighted,
}"
@@ -266,10 +269,12 @@ const messages = defineMessages({
: world.icon
"
size="48px"
no-shadow
class="!rounded-[14px]"
/>
<div class="flex flex-col justify-between h-full">
<div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
<div class="flex flex-col justify-center gap-0.5 h-full">
<div class="flex items-center gap-1.5">
<div class="text-base text-contrast font-semibold truncate">
{{ world.name }}
</div>
<TagItem
@@ -310,42 +315,38 @@ const messages = defineMessages({
}}
</span>
</template>
<template v-else>
<SignalIcon
v-tooltip="serverStatus ? `${serverStatus.ping}ms` : null"
aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping || 0)}: var(--color-green)`"
stroke-width="3px"
class="shrink-0"
:class="{
'smart-clickable:allow-pointer-events': serverStatus,
}"
<div v-else class="flex items-center gap-2">
<ServerOnlinePlayers
:online="serverStatus.players?.online ?? 0"
status-online
hide-label
/>
<Tooltip :disabled="!hasPlayersTooltip">
<span :class="{ 'cursor-help': hasPlayersTooltip }">
{{
formatMessage(messages.playersOnline, {
count: formatNumber(serverStatus.players?.online ?? 0),
})
}}
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.name">
{{ player.name }}
</span>
</div>
</template>
</Tooltip>
</template>
</template>
<template v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
{{ formatMessage(messages.offline) }}
</template>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div class="flex items-center gap-1.5 text-sm text-secondary">
<template v-if="instanceId">
<router-link
data-no-card-click
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/instance/${instanceId}`"
>
<Avatar
:src="getInstanceIconUrl(instanceIcon)"
size="16px"
:tint-by="instanceId"
class="shrink-0"
no-shadow
/>
<span class="truncate">{{ instanceName }}</span>
</router-link>
<BulletDivider class="shrink-0" />
</template>
<div
v-tooltip="world.last_played ? formatDateTime(world.last_played) : null"
class="w-fit shrink-0"
@@ -354,29 +355,10 @@ const messages = defineMessages({
}"
>
<template v-if="world.last_played">
{{
formatMessage(commonMessages.playedLabel, {
ago: formatRelativeTime(dayjs(world.last_played).toISOString()),
})
}}
{{ formatRelativeTime(dayjs(world.last_played).toISOString()) }}
</template>
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
</div>
<template v-if="instanceId">
<router-link
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/instance/${instanceId}`"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
size="16px"
:tint-by="instanceId"
class="shrink-0"
/>
<span class="truncate">{{ instanceName }}</span>
</router-link>
</template>
</div>
</div>
<div
@@ -425,7 +407,7 @@ const messages = defineMessages({
v-else
v-tooltip="
quarantined
? 'This instance has been locked'
? formatMessage(messages.instanceLocked)
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
@@ -449,6 +431,8 @@ const messages = defineMessages({
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
type="colored"
color="brand"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
@@ -457,7 +441,7 @@ const messages = defineMessages({
</Button>
<TeleportOverflowMenu
type="quiet"
label="More options"
:label="formatMessage(messages.moreOptions)"
:options="[
{
id: 'play-instance',
@@ -595,6 +579,10 @@ const messages = defineMessages({
</SmartClickable>
</template>
<style scoped lang="scss">
.clickable-card:has([data-no-card-click]:hover) {
--hover-brightness: 1;
}
.world-item-highlighted {
position: relative;
animation: fade-highlight 4s ease-out;
@@ -3,10 +3,12 @@ import type { CommandPayload } from "./CommandPayload";
import type { FriendPayload } from "./FriendPayload";
import type { InstallJobSnapshot } from "./InstallJobSnapshot";
import type { InstanceBulkUpdateProgressPayload } from "./InstanceBulkUpdateProgressPayload";
import type { InstanceGroupsChangedPayload } from "./InstanceGroupsChangedPayload";
import type { InstancePayload } from "./InstancePayload";
import type { LoadingPayload } from "./LoadingPayload";
import type { LogPayload } from "./LogPayload";
import type { OnboardingChecklist } from "./OnboardingChecklist";
import type { ProcessPayload } from "./ProcessPayload";
import type { WarningPayload } from "./WarningPayload";
export type AppEvent = { "type": "loading", "payload": LoadingPayload } | { "type": "process", "payload": ProcessPayload } | { "type": "instance", "payload": InstancePayload } | { "type": "instance_bulk_update_progress", "payload": InstanceBulkUpdateProgressPayload } | { "type": "install_job", "payload": InstallJobSnapshot } | { "type": "command", "payload": CommandPayload } | { "type": "warning", "payload": WarningPayload } | { "type": "friend", "payload": FriendPayload } | { "type": "notification", "payload": unknown } | { "type": "log", "payload": LogPayload } | { "type": "ads_consent_required", "payload": boolean };
export type AppEvent = { "type": "loading", "payload": LoadingPayload } | { "type": "process", "payload": ProcessPayload } | { "type": "instance", "payload": InstancePayload } | { "type": "instance_groups_changed", "payload": InstanceGroupsChangedPayload } | { "type": "onboarding_checklist", "payload": OnboardingChecklist } | { "type": "instance_bulk_update_progress", "payload": InstanceBulkUpdateProgressPayload } | { "type": "install_job", "payload": InstallJobSnapshot } | { "type": "command", "payload": CommandPayload } | { "type": "warning", "payload": WarningPayload } | { "type": "friend", "payload": FriendPayload } | { "type": "notification", "payload": unknown } | { "type": "log", "payload": LogPayload } | { "type": "ads_consent_required", "payload": boolean };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstanceGroupsChangedPayload = { instance_ids: Array<string>, };

Some files were not shown because too many files have changed in this diff Show More