diff --git a/Cargo.lock b/Cargo.lock index acce146a94..212a0f698a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11445,6 +11445,7 @@ dependencies = [ "enumset", "hyper 1.7.0", "hyper-util", + "image", "native-dialog", "objc2-app-kit", "paste", @@ -11452,6 +11453,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2", "tauri", "tauri-build", "tauri-plugin-deep-link", diff --git a/apps/app-frontend/eslint.config.mjs b/apps/app-frontend/eslint.config.mjs index b8555099f1..5bed084703 100644 --- a/apps/app-frontend/eslint.config.mjs +++ b/apps/app-frontend/eslint.config.mjs @@ -4,4 +4,9 @@ export default config.append([ { ignores: ['src/generated/app-events/*.ts', 'src/generated/app-events/postcard/**'], }, + { + rules: { + 'turbo/no-undeclared-env-vars': ['error', { allowList: ['^DEV$', '^PROD$'] }], + }, + }, ]) diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 52f5c6d5c7..52f7a10e15 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -14,7 +14,7 @@ import { ChevronLeftIcon, ChevronRightIcon, CompassIcon, - ImagesIcon, + ImageIcon, LogInIcon, LogOutIcon, NewspaperIcon, @@ -61,7 +61,7 @@ import { UserRoleIcon, useVIntl, } from '@modrinth/ui' -import { renderString } from '@modrinth/utils' +import { renderString } from '@modrinth/utils/parse' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { getVersion } from '@tauri-apps/api/app' import { convertFileSrc, invoke } from '@tauri-apps/api/core' @@ -91,8 +91,6 @@ 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' @@ -100,12 +98,14 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue' import SplashScreen from '@/components/ui/SplashScreen.vue' import SurveyPopup from '@/components/ui/SurveyPopup.vue' +import SyncInstancesUpdateModal from '@/components/ui/sync-instances-update-modal/index.vue' import WindowControls from '@/components/ui/WindowControls.vue' import { useCheckDisableMouseover } from '@/composables/macCssFix.js' import { useAppEvent } from '@/composables/use-app-event' import { useAppSettings } from '@/composables/use-app-settings.ts' import { useError } from '@/composables/use-error.js' import { useInstanceMetadataRefresh } from '@/composables/use-instance-metadata-refresh' +import { useQuickInstanceLimit } from '@/composables/use-quick-instance-limit.ts' import { isDarkTheme, useTheme } from '@/composables/use-theme.ts' import { config } from '@/config' import { getAccountAppearance, rememberAccountAppearance } from '@/helpers/account-appearance.ts' @@ -124,7 +124,6 @@ import { install_create_modpack_instance, install_get_modpack_preview } from '@/ import { can_current_user_use_shared_instances, get as getInstance, - get_global_synced_options, run, set_global_synced_option, } from '@/helpers/instance' @@ -137,8 +136,9 @@ import { setActive, } from '@/helpers/mr_auth.ts' import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' -import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' +import { appSettingsKeys, get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' +import { globalSyncedOptionsQueryOptions, syncedOptionsKeys } from '@/helpers/synced-options' import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts' import { get_user_preferences } from '@/helpers/user-preferences.ts' import { parse_modrinth_user_link } from '@/helpers/users' @@ -152,8 +152,12 @@ import { setRestartAfterPendingUpdate, } from '@/helpers/utils.js' import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts' -import i18n from '@/i18n.config' -import { instanceKeys, screenshotKeys } from '@/pages/instance/query-options' +import i18n, { setLocale } from '@/i18n.config' +import { + instanceKeys, + instanceListQueryOptions, + screenshotKeys, +} from '@/pages/instance/query-options' import { appUpdateState, downloadAvailableAppUpdate, @@ -178,8 +182,6 @@ import { setupLoadingStateProvider } from '@/providers/setup/loading-state' import { setupAppUserPreferencesProvider } from '@/providers/setup/user-preferences.ts' import { appMessages } from '@/utils/app-messages' -import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' -import { get_available_capes, get_available_skins } from './helpers/skins' import { AppNotificationManager } from './providers/app-notifications' import { AppPopupNotificationManager } from './providers/app-popup-notifications' import { @@ -189,6 +191,7 @@ import { const appSettings = useAppSettings() const appTheme = useTheme() +const quickInstances = useQuickInstanceLimit() const router = useRouter() const route = useRoute() const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider() @@ -384,6 +387,7 @@ const { handleModpackDuplicateCreateAnyway, handleModpackDuplicateGoToInstance, onboardingChecklist, + tags, } = setupProviders( tauriApiClient, notificationManager, @@ -452,8 +456,7 @@ const isDevEnvironment = ref(false) const stateInitialized = ref(false) const globalSyncedOptionsQuery = useQuery({ - queryKey: ['global-synced-options'], - queryFn: get_global_synced_options, + ...globalSyncedOptionsQueryOptions(), enabled: computed(() => stateInitialized.value), }) @@ -539,6 +542,23 @@ const { formatMessage } = useVIntl() const formatBytes = useFormatBytes() const messages = defineMessages({ + syncUpdateTitle: { + id: 'app.sync-instances-update.notification.title', + defaultMessage: 'Sync your instances', + }, + syncUpdateDescription: { + id: 'app.sync-instances-update.notification.description', + defaultMessage: + 'Keep game settings, servers, resource packs, and more in sync across your instances.', + }, + syncUpdateView: { + id: 'app.sync-instances-update.notification.view-update', + defaultMessage: 'View update', + }, + syncUpdateDismiss: { + id: 'app.sync-instances-update.notification.dismiss', + defaultMessage: 'Dismiss', + }, warning: { id: 'app.notification.warning', defaultMessage: 'Warning' }, goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' }, goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' }, @@ -705,16 +725,9 @@ function handleAdsConsentRequired(required) { } async function setupApp() { + tags.initialize() await onboardingChecklist.initialize() - if (shouldShowNewIconEditorNotification(showChecklist.value)) { - addPopupNotification({ - contentType: 'custom', - component: NewIconEditorNotification, - autoCloseMs: null, - }) - } - const { native_decorations, theme, @@ -725,6 +738,11 @@ async function setupApp() { toggle_sidebar, sync_theme_across_devices, sync_behavior_across_devices, + sync_features_across_devices, + show_files_tab_in_instances, + show_worlds_tab_in_instances, + show_screenshots_tab_in_instances, + show_skin_selector_in_sidebar, developer_mode, feature_flags, pending_update_toast_for_version, @@ -732,7 +750,7 @@ async function setupApp() { // Initialize locale from saved settings if (locale) { - i18n.global.locale.value = locale + await setLocale(locale) } Object.assign(appSettings.featureFlags, feature_flags) @@ -749,10 +767,23 @@ async function setupApp() { appTheme.advancedRendering = advanced_rendering appTheme.syncAcrossDevices = sync_theme_across_devices appSettings.syncBehaviorAcrossDevices = sync_behavior_across_devices + appSettings.syncFeaturesAcrossDevices = sync_features_across_devices appSettings.hideNametagSkinsPage = hide_nametag_skins_page appSettings.toggleSidebar = toggle_sidebar + appSettings.showFilesTabInInstances = show_files_tab_in_instances + appSettings.showWorldsTabInInstances = show_worlds_tab_in_instances + appSettings.showScreenshotsTabInInstances = show_screenshots_tab_in_instances + appSettings.showSkinSelectorInSidebar = show_skin_selector_in_sidebar appSettings.devMode = developer_mode stateInitialized.value = true + await nextTick() + if ( + appSettings.getFeatureFlag('show_sync_instances_update_modal') || + (pending_update_toast_for_version === version && + (await queryClient.fetchQuery(instanceListQueryOptions())).length > 0) + ) { + showSyncInstancesUpdateNotification() + } await getCurrentWindow().onResized(async () => { isMaximized.value = await getCurrentWindow().isMaximized() @@ -804,14 +835,6 @@ async function setupApp() { get_opening_command().then(handleCommand) fetchCredentials() - try { - const skins = (await get_available_skins()) ?? [] - const capes = (await get_available_capes()) ?? [] - generateSkinPreviews(skins, capes) - } catch (error) { - console.warn('Failed to generate skin previews in app setup.', error) - } - if (pending_update_toast_for_version !== null) { const settings = await getSettings() settings.pending_update_toast_for_version = null @@ -1038,9 +1061,53 @@ const updateToPlayModal = ref() const modrinthLoginModal = ref() const appSettingsModal = ref() +const syncInstancesUpdateModal = ref() +let syncInstancesUpdateNotificationId = null + +function showSyncInstancesUpdateNotification() { + if ( + popupNotificationManager + .getNotifications() + .some((notification) => notification.id === syncInstancesUpdateNotificationId) + ) { + return + } + + const notification = addPopupNotification({ + contentType: 'standard', + title: formatMessage(messages.syncUpdateTitle), + text: formatMessage(messages.syncUpdateDescription), + type: 'info', + hideIcon: true, + autoCloseMs: null, + buttons: [ + { + label: formatMessage(messages.syncUpdateDismiss), + color: 'standard', + action: () => popupNotificationManager.removeNotification(notification.id), + }, + { + label: formatMessage(messages.syncUpdateView), + color: 'brand', + action: () => syncInstancesUpdateModal.value?.show(), + }, + ], + }) + syncInstancesUpdateNotificationId = notification.id +} + provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile()) provide(appSettingsModalOpenSyncedOptionsKey, () => appSettingsModal.value?.showSyncedOptions()) +watch( + () => appSettings.getFeatureFlag('show_sync_instances_update_modal'), + (enabled) => { + if (enabled && stateInitialized.value) { + showSyncInstancesUpdateNotification() + } + }, +) + watch(incompatibilityWarningModal, (modal) => { if (modal) { setContentIncompatibilityWarningModal(modal) @@ -1082,7 +1149,7 @@ watch( appTheme.preferred = selectedTheme } if (i18n.global.locale.value !== locale) { - i18n.global.locale.value = locale + await setLocale(locale) } if (appTheme.syncAcrossDevices && settings.theme !== selectedTheme) { @@ -1096,7 +1163,6 @@ watch( if (behavior && appSettings.syncBehaviorAcrossDevices) { const behaviorFeatureFlags = { - worlds_in_home: behavior.show_jump_in, compact_instance_cards: behavior.compact_instance_cards, show_instance_play_time: behavior.show_play_time, skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks, @@ -1120,25 +1186,53 @@ watch( settingsChanged = true } + for (const [flag, value] of Object.entries(behaviorFeatureFlags)) { + if (settings.feature_flags[flag] !== value) { + settings.feature_flags[flag] = value + settingsChanged = true + } + } + } + + if (behavior && appSettings.syncFeaturesAcrossDevices) { + const featureFlags = { + worlds_in_home: behavior.show_jump_in, + } + const featureSettings = { + show_files_tab_in_instances: 'showFilesTabInInstances', + show_worlds_tab_in_instances: 'showWorldsTabInInstances', + show_screenshots_tab_in_instances: 'showScreenshotsTabInInstances', + show_skin_selector_in_sidebar: 'showSkinSelectorInSidebar', + } + for (const [key, stateKey] of Object.entries(featureSettings)) { + const value = behavior[key] ?? settings[key] + appSettings[stateKey] = value + if (settings[key] !== value) { + settings[key] = value + settingsChanged = true + } + } + Object.assign(appSettings.featureFlags, featureFlags) + if (typeof behavior.quick_instance_count === 'number') { + quickInstances.setLimit(behavior.quick_instance_count) + } + const showAllScreenshots = behavior.show_all_screenshots if (typeof showAllScreenshots === 'boolean') { const globalSyncedOptions = globalSyncedOptionsQuery.data.value ?? - (await queryClient.fetchQuery({ - queryKey: ['global-synced-options'], - queryFn: get_global_synced_options, - })) + (await queryClient.fetchQuery(globalSyncedOptionsQueryOptions())) if (globalSyncedOptions.screenshots !== showAllScreenshots) { const updatedGlobalSyncedOptions = await set_global_synced_option( 'screenshots', showAllScreenshots, ) - queryClient.setQueryData(['global-synced-options'], updatedGlobalSyncedOptions) + queryClient.setQueryData(syncedOptionsKeys.global, updatedGlobalSyncedOptions) await queryClient.invalidateQueries({ queryKey: screenshotKeys.all }) } } - for (const [flag, value] of Object.entries(behaviorFeatureFlags)) { + for (const [flag, value] of Object.entries(featureFlags)) { if (settings.feature_flags[flag] !== value) { settings.feature_flags[flag] = value settingsChanged = true @@ -1148,6 +1242,7 @@ watch( if (settingsChanged) { await setSettings(settings) + queryClient.setQueryData(appSettingsKeys.all, settings) } }) .catch(handleError) @@ -1419,8 +1514,10 @@ async function fetchIntercomToken() { } watch( - [showAd, adConsentAvailable], - async ([showAds, canManageConsent]) => { + [stateInitialized, showAd, adConsentAvailable], + async ([ready, showAds, canManageConsent]) => { + if (!ready) return + if (showAds) { await init_ads_window(true) return @@ -2035,6 +2132,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) + @@ -2082,7 +2180,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload) > - + - + diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index 865d142a8a..70cc46e9ed 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -104,7 +104,7 @@ import { useVIntl, } from '@modrinth/ui' import type { Ref } from 'vue' -import { computed, ref } from 'vue' +import { computed, onUnmounted, ref } from 'vue' import { useAppEvent } from '@/composables/use-app-event' import { handleSevereError } from '@/composables/use-error.js' @@ -116,7 +116,7 @@ import { set_default_user, users, } from '@/helpers/auth' -import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts' +import { getPlayerHeadUrl } from '@/helpers/rendering/player-head' import type { Skin } from '@/helpers/skins' import { get_available_skins } from '@/helpers/skins' @@ -138,7 +138,23 @@ const accounts: Ref = ref([]) const loginDisabled = ref(false) const defaultUser = ref() const equippedSkin = ref(null) -const headUrlCache = ref(new Map()) +const equippedHeadUrl = ref() +let headRequest = 0 + +async function updateHeadUrl(skin: Skin | null) { + const request = ++headRequest + if (equippedHeadUrl.value) URL.revokeObjectURL(equippedHeadUrl.value) + equippedHeadUrl.value = undefined + if (!skin) return + const url = await getPlayerHeadUrl(skin) + if (request !== headRequest) URL.revokeObjectURL(url) + else equippedHeadUrl.value = url +} + +onUnmounted(() => { + headRequest++ + if (equippedHeadUrl.value) URL.revokeObjectURL(equippedHeadUrl.value) +}) async function refreshValues() { defaultUser.value = await get_default_user().catch(handleError) @@ -150,19 +166,10 @@ async function refreshValues() { const skins = await get_available_skins() equippedSkin.value = skins.find((skin) => skin.is_equipped) ?? null - if (equippedSkin.value) { - try { - const headUrl = await getPlayerHeadUrl(equippedSkin.value) - headUrlCache.value = new Map(headUrlCache.value).set( - equippedSkin.value.texture_key, - headUrl, - ) - } catch (error) { - console.warn('Failed to get head render for equipped skin:', error) - } - } + await updateHeadUrl(equippedSkin.value) } catch { equippedSkin.value = null + void updateHeadUrl(null) } } @@ -170,8 +177,7 @@ async function setEquippedSkin(skin: Skin) { equippedSkin.value = skin try { - const headUrl = await getPlayerHeadUrl(skin) - headUrlCache.value = new Map(headUrlCache.value).set(skin.texture_key, headUrl) + await updateHeadUrl(skin) } catch (error) { console.warn('Failed to get head render for equipped skin:', error) } @@ -197,7 +203,7 @@ const selectedAccount = computed(() => const avatarUrl = computed(() => { if (equippedSkin.value?.texture_key) { - const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key) + const cachedUrl = equippedHeadUrl.value if (cachedUrl) { return cachedUrl } @@ -214,7 +220,7 @@ function getAccountAvatarUrl(account: MinecraftCredential) { account.profile.id === selectedAccount.value?.profile?.id && equippedSkin.value?.texture_key ) { - const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key) + const cachedUrl = equippedHeadUrl.value if (cachedUrl) { return cachedUrl } diff --git a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue index 99cc39a147..7387eaca6a 100644 --- a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue +++ b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue @@ -16,6 +16,10 @@ import { useRouter } from 'vue-router' import NavButton from '@/components/ui/NavButton.vue' import { useAppEvent } from '@/composables/use-app-event' import { handleSevereError } from '@/composables/use-error.js' +import { + QUICK_INSTANCE_LIMIT_MAX, + useQuickInstanceLimit, +} from '@/composables/use-quick-instance-limit.ts' import { trackEvent } from '@/helpers/analytics' import { getInstanceIconUrl, kill, run } from '@/helpers/instance' import { get_all } from '@/helpers/process' @@ -24,8 +28,6 @@ import { instanceListQueryOptions } from '@/pages/instance/query-options' const ITEM_SIZE = 52 const APPROX_USED_VERTICAL_SPACE = 475 // doesn't need to be exact lol just close enough so there's a little gap and no overflow -const STORAGE_KEY = 'modrinth-quick-instance-count' - const { handleError } = injectNotificationManager() const instancesQuery = useQuery(instanceListQueryOptions()) const router = useRouter() @@ -54,12 +56,14 @@ const allInstances = computed(() => }), ) const dragging = ref(false) +const quickInstances = useQuickInstanceLimit() -const stored = localStorage.getItem(STORAGE_KEY) -const userLimit = ref(stored === null ? null : Number(stored)) - -const maxVisible = computed(() => Math.min(maxAuto.value, allInstances.value.length)) -const visibleCount = computed(() => Math.min(userLimit.value ?? maxVisible.value, maxVisible.value)) +const maxVisible = computed(() => + Math.min(maxAuto.value, allInstances.value.length, QUICK_INSTANCE_LIMIT_MAX), +) +const visibleCount = computed(() => + Math.min(quickInstances.limit.value ?? maxVisible.value, maxVisible.value), +) const recentInstances = computed(() => allInstances.value.slice(0, visibleCount.value)) const canDrag = computed(() => maxVisible.value > 0) const showOverdrag = ref(false) @@ -74,11 +78,9 @@ const updateMaxAuto = () => { const setLimit = (count) => { const clamped = Math.max(0, Math.min(count, maxVisible.value)) if (clamped >= maxVisible.value) { - userLimit.value = null - localStorage.removeItem(STORAGE_KEY) + quickInstances.setLimit(null) } else { - userLimit.value = clamped - localStorage.setItem(STORAGE_KEY, String(clamped)) + quickInstances.setLimit(clamped) } } diff --git a/apps/app-frontend/src/components/ui/instance/SyncedContentModal.vue b/apps/app-frontend/src/components/ui/instance/SyncedContentModal.vue new file mode 100644 index 0000000000..9dd3255f35 --- /dev/null +++ b/apps/app-frontend/src/components/ui/instance/SyncedContentModal.vue @@ -0,0 +1,300 @@ + + + diff --git a/apps/app-frontend/src/components/ui/library/index.vue b/apps/app-frontend/src/components/ui/library/index.vue index 843d420268..20000b683f 100644 --- a/apps/app-frontend/src/components/ui/library/index.vue +++ b/apps/app-frontend/src/components/ui/library/index.vue @@ -19,6 +19,8 @@ import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInsta import { FAVORITES_GROUP_ID } from '@/helpers/instance-groups' import type { GameInstance } from '@/helpers/types' +import { libraryScrollTop } from './view-state' + const props = defineProps<{ instances: GameInstance[] }>() @@ -57,6 +59,21 @@ const { toggleLibraryInstanceSelection, } = provideLibrary(toRef(props, 'instances')) +let restoreScrollFrame: number | undefined +let unmounted = false +const animationsReady = ref(false) +watch(libraryGroupsLoaded, async (loaded) => { + if (!loaded || animationsReady.value) return + await nextTick() + if (unmounted) return + restoreScrollFrame = requestAnimationFrame(() => { + document.querySelector('.app-viewport')?.scrollTo(0, libraryScrollTop.value) + restoreScrollFrame = requestAnimationFrame(() => { + animationsReady.value = true + }) + }) +}) + const hasActiveFilters = computed(() => Object.values(filters.value).some((selectedValues) => selectedValues.length > 0), ) @@ -161,6 +178,8 @@ function onGroupDragEnd() { } onUnmounted(() => { + unmounted = true + if (restoreScrollFrame !== undefined) cancelAnimationFrame(restoreScrollFrame) document.documentElement.classList.remove(GROUP_REORDERING_CLASS) }) @@ -258,6 +277,7 @@ watch(selectedLibraryInstances, (selectedInstances) => { { >
{ class="min-w-0" > (focusableSelector)] + const boundary = event.shiftKey ? controls[0] : controls.at(-1) + if (event.target !== boundary) return + const nextIndex = index + (event.shiftKey ? -1 : 1) + const nextInstance = props.instanceGroup.instances[nextIndex] + if (!nextInstance) return + event.preventDefault() + focusedInstanceId.value = nextInstance.id + await nextTick() + const nextCard = instanceGridContent.value?.querySelector(`[data-instance-index="${nextIndex}"]`) + const nextControls = [...(nextCard?.querySelectorAll(focusableSelector) ?? [])] + const target = event.shiftKey ? nextControls.at(-1) : nextControls[0] + target?.focus() +} + +function handleCardBlur(event: FocusEvent, instanceId: string) { + if ((event.currentTarget as HTMLElement).contains(event.relatedTarget as Node | null)) return + if (focusedInstanceId.value === instanceId) focusedInstanceId.value = null +} + const instanceGridHeight = ref() const deletingGroup = ref(false) const groupName = ref(props.instanceGroup.key) @@ -97,6 +211,7 @@ const isGroupToggleBlocked = computed( let shouldSkipGroupToggle = false let groupToggleEventToSkip: MouseEvent | undefined let instanceGridResizeObserver: ResizeObserver | undefined +let libraryResizeObserver: ResizeObserver | undefined let instanceGridObserverActivationTimeout: ReturnType | undefined const emit = defineEmits<{ @@ -357,9 +472,21 @@ function startInstanceGridResizeObserver() { }, INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY) } -onActivated(startInstanceGridResizeObserver) -onDeactivated(stopInstanceGridResizeObserver) -onMounted(startInstanceGridResizeObserver) +onUnmounted(() => { + clearTimeout(cardMoveTimer) + stopInstanceGridResizeObserver() + libraryResizeObserver?.disconnect() +}) +onMounted(() => { + remSize.value = parseFloat(getComputedStyle(document.documentElement).fontSize) + startInstanceGridResizeObserver() + const library = groupDropTarget.value?.closest('[data-library-page-background]') + if (library) { + libraryResizeObserver = new ResizeObserver(syncScrollState) + libraryResizeObserver.observe(library) + } +}) +watch([gridHeight, () => props.instanceGroup.instances], () => nextTick(syncScrollState)) - +
@@ -535,11 +536,12 @@ defineExpose({ getAddSkinButtonElement }) v-else class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6" > - {{ formatMessage(commonMessages.editButton) }} - + diff --git a/apps/app-frontend/src/components/ui/sync-instances-update-modal/index.vue b/apps/app-frontend/src/components/ui/sync-instances-update-modal/index.vue new file mode 100644 index 0000000000..56b145df19 --- /dev/null +++ b/apps/app-frontend/src/components/ui/sync-instances-update-modal/index.vue @@ -0,0 +1,395 @@ + + + diff --git a/apps/app-frontend/src/components/ui/sync-instances-update-modal/use-sync.ts b/apps/app-frontend/src/components/ui/sync-instances-update-modal/use-sync.ts new file mode 100644 index 0000000000..37bfbff2ff --- /dev/null +++ b/apps/app-frontend/src/components/ui/sync-instances-update-modal/use-sync.ts @@ -0,0 +1,251 @@ +import { injectNotificationManager } from '@modrinth/ui' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, ref, watch } from 'vue' + +import { isSyncedOptionAvailable, set_global_synced_option } from '@/helpers/instance' +import { + canSourceMultiplayerServers, + gameOptionsSyncSourcesQueryOptions, + globalSyncedOptionsQueryOptions, + syncedOptionsKeys, +} from '@/helpers/synced-options' +import { syncedPackKeys } from '@/helpers/synced-packs' +import { instanceKeys, instanceListQueryOptions } from '@/pages/instance/query-options' + +export const syncUpdateOptions = ( + [ + 'game_options', + 'multiplayer_servers', + 'command_history', + 'creative_hotbars', + 'resource_packs', + 'data_packs', + ] as const +).filter(isSyncedOptionAvailable) + +export type SyncUpdateOption = (typeof syncUpdateOptions)[number] + +type SyncUpdateOptionState = Record +type SyncUpdateSourceState = Partial> + +function createOptionState(value = false): SyncUpdateOptionState { + return Object.fromEntries( + syncUpdateOptions.map((option) => [option, value]), + ) as SyncUpdateOptionState +} + +export function useSyncInstancesUpdate() { + const queryClient = useQueryClient() + const { handleError } = injectNotificationManager() + const isOpen = ref(false) + const draftInitialized = ref(false) + const initialOptions = ref(createOptionState()) + const draftOptions = ref(createOptionState()) + const draftSourceInstanceIds = ref({}) + const sourceOptions = ref([]) + const sourceInstanceId = ref('') + const needsGameOptionsSource = computed(() => sourceOptions.value.includes('game_options')) + const needsServerSource = computed(() => sourceOptions.value.includes('multiplayer_servers')) + const globalOptionsQuery = useQuery({ + ...globalSyncedOptionsQueryOptions(), + enabled: isOpen, + }) + const gameSourcesQuery = useQuery({ + ...gameOptionsSyncSourcesQueryOptions(), + enabled: needsGameOptionsSource, + }) + const instancesQuery = useQuery({ + ...instanceListQueryOptions(), + staleTime: 0, + enabled: computed( + () => + sourceOptions.value.length > 0 && + (!needsGameOptionsSource.value || needsServerSource.value), + ), + }) + const sources = computed(() => + needsGameOptionsSource.value + ? (gameSourcesQuery.data.value ?? []).map((source) => ({ + id: source.source_id, + name: source.name, + icon_path: source.icon_path, + eligible: + source.eligible && + (!needsServerSource.value || + (instancesQuery.data.value ?? []).some( + (instance) => + instance.id === source.source_id && canSourceMultiplayerServers(instance), + )), + })) + : (instancesQuery.data.value ?? []).map((instance) => ({ + id: instance.id, + name: instance.name, + icon_path: instance.icon_path, + eligible: + instance.install_stage === 'installed' && + !instance.quarantined && + (!needsServerSource.value || canSourceMultiplayerServers(instance)), + })), + ) + const sourcesLoading = computed( + () => + (needsGameOptionsSource.value && + (gameSourcesQuery.isPending.value || gameSourcesQuery.isFetching.value)) || + ((!needsGameOptionsSource.value || needsServerSource.value) && + (instancesQuery.isPending.value || instancesQuery.isFetching.value)), + ) + const sourcesError = computed( + () => + (needsGameOptionsSource.value && gameSourcesQuery.isError.value) || + ((!needsGameOptionsSource.value || needsServerSource.value) && instancesQuery.isError.value), + ) + const allSynced = computed( + () => draftInitialized.value && syncUpdateOptions.every((option) => draftOptions.value[option]), + ) + + function initializeDraft() { + const globalOptions = globalOptionsQuery.data.value + if (!globalOptions) return + + const options = createOptionState() + for (const option of syncUpdateOptions) { + options[option] = globalOptions[option] + } + initialOptions.value = { ...options } + draftOptions.value = options + draftSourceInstanceIds.value = {} + draftInitialized.value = true + } + + watch( + () => globalOptionsQuery.data.value, + () => { + if (isOpen.value && !draftInitialized.value) initializeDraft() + }, + ) + + watch([sources, sourceOptions], ([candidates]) => { + if (!candidates.some((source) => source.id === sourceInstanceId.value && source.eligible)) { + sourceInstanceId.value = candidates.find((source) => source.eligible)?.id ?? '' + } + }) + + const syncMutation = useMutation({ + mutationKey: syncedOptionsKeys.set, + mutationFn: async ( + changes: { + option: SyncUpdateOption + enabled: boolean + baseInstanceId?: string + }[], + ) => { + for (const { option, enabled, baseInstanceId } of changes) { + const updated = await set_global_synced_option(option, enabled, baseInstanceId) + queryClient.setQueryData(syncedOptionsKeys.global, updated) + initialOptions.value[option] = enabled + } + }, + onMutate: () => queryClient.cancelQueries({ queryKey: syncedOptionsKeys.global }), + onError: handleError, + onSettled: () => { + void Promise.all([ + queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }), + queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }), + queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.gameSources }), + queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }), + queryClient.invalidateQueries({ queryKey: instanceKeys.all }), + queryClient.invalidateQueries({ queryKey: ['worlds'] }), + queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }), + ]) + }, + }) + + function beginDraft() { + isOpen.value = true + draftInitialized.value = false + sourceOptions.value = [] + sourceInstanceId.value = '' + initializeDraft() + } + + function finishDraft() { + isOpen.value = false + draftInitialized.value = false + draftSourceInstanceIds.value = {} + sourceOptions.value = [] + sourceInstanceId.value = '' + } + + function stageOptions( + options: readonly SyncUpdateOption[], + enabled: boolean, + baseInstanceId?: string, + ) { + for (const option of options.filter(isSyncedOptionAvailable)) { + draftOptions.value[option] = enabled + if (enabled && !initialOptions.value[option] && baseInstanceId) { + draftSourceInstanceIds.value[option] = baseInstanceId + } else { + draftSourceInstanceIds.value[option] = undefined + } + } + } + + function isInitiallyEnabled(option: SyncUpdateOption) { + return initialOptions.value[option] + } + + async function applyDraft() { + if (!draftInitialized.value) return + + const changes = syncUpdateOptions + .filter((option) => draftOptions.value[option] !== initialOptions.value[option]) + .map((option) => ({ + option, + enabled: draftOptions.value[option], + baseInstanceId: draftOptions.value[option] + ? draftSourceInstanceIds.value[option] + : undefined, + })) + if (changes.length === 0) return + + await syncMutation.mutateAsync(changes) + draftSourceInstanceIds.value = {} + } + + function chooseSource(options: readonly SyncUpdateOption[]) { + sourceInstanceId.value = '' + sourceOptions.value = options.filter(isSyncedOptionAvailable) + } + + async function retrySources() { + const results = await Promise.all([ + ...(needsGameOptionsSource.value ? [gameSourcesQuery.refetch({ cancelRefetch: false })] : []), + ...(!needsGameOptionsSource.value || needsServerSource.value + ? [instancesQuery.refetch({ cancelRefetch: false })] + : []), + ]) + return { isSuccess: results.every((result) => result.isSuccess) } + } + + return { + isOpen, + globalOptionsQuery, + allSynced, + draftInitialized, + draftOptions, + syncMutation, + sourceOptions, + sourceInstanceId, + sources, + sourcesLoading, + sourcesError, + beginDraft, + finishDraft, + stageOptions, + isInitiallyEnabled, + applyDraft, + chooseSource, + retrySources, + } +} diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue index cc1a117de4..6a0b4b04b6 100644 --- a/apps/app-frontend/src/components/ui/world/InstanceItem.vue +++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue @@ -24,7 +24,7 @@ import { useRelativeTime, useVIntl, } from '@modrinth/ui' -import { capitalizeString } from '@modrinth/utils' +import { capitalizeString } from '@modrinth/utils/utils' import type { Dayjs } from 'dayjs' import { computed, onMounted, ref, useTemplateRef } from 'vue' import { useRouter } from 'vue-router' diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue index c226d7be0d..c7b1282ba2 100644 --- a/apps/app-frontend/src/components/ui/world/WorldItem.vue +++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue @@ -18,15 +18,16 @@ import { UserIcon, XIcon, } from '@modrinth/assets' -import type { ButtonMenuOption, MessageDescriptor } from '@modrinth/ui' import { Avatar, BulletDivider, Button, + type ButtonMenuOption, commonMessages, ContextMenu, defineMessages, injectNotificationManager, + type MessageDescriptor, SmartClickable, TagItem, TeleportOverflowMenu, @@ -35,7 +36,8 @@ import { useRelativeTime, useVIntl, } from '@modrinth/ui' -import { getPingLevel } from '@modrinth/utils' +import { getPingLevel } from '@modrinth/utils/utils' +import { autoToHTML } from '@sfirew/minecraft-motd-parser' import dayjs from 'dayjs' import { Tooltip } from 'floating-vue' import type { Component } from 'vue' @@ -276,6 +278,15 @@ const messages = defineMessages({ }, }) +const incompatibleVersionTooltip = computed(() => ({ + content: `${autoToHTML( + formatMessage(messages.incompatibleVersion, { + version: props.serverStatus?.version?.name ?? '', + }), + )}`, + html: true, +})) + const cardOptions = useTemplateRef('cardOptions') const showStop = computed( () => @@ -507,16 +518,12 @@ function openContextMenu(event: MouseEvent) { {{ formatMessage(commonMessages.loadingLabel) }}