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>
@@ -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:*",
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
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>
|
||||
@@ -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 doesn’t} other {# instances don’t}} 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>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
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>, };
|
||||