diff --git a/.github/workflows/frontend-docker.yml b/.github/workflows/frontend-docker.yml index cd63875f11..48c5f0c76b 100644 --- a/.github/workflows/frontend-docker.yml +++ b/.github/workflows/frontend-docker.yml @@ -73,7 +73,7 @@ jobs: - name: Build frontend run: pnpm web:build env: - NITRO_PRESET: node-server + NITRO_PRESET: node_cluster BUILD_ENV: ${{ steps.meta.outputs.env }} CF_PAGES_BRANCH: ${{ github.ref_name }} CF_PAGES_COMMIT_SHA: ${{ github.sha }} diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index abe6e55c0f..7eea9d5397 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -32,6 +32,7 @@ "@tauri-apps/plugin-window-state": "^2.2.2", "@types/three": "^0.172.0", "@vueuse/core": "^11.1.0", + "ace-builds": "^1.43.5", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", "fuse.js": "^6.6.2", @@ -45,6 +46,7 @@ "vue-i18n": "^10.0.0", "vue-router": "^4.6.0", "vue-virtual-scroller": "v2.0.0-beta.8", + "vue3-ace-editor": "^2.2.4", "vuedraggable": "^4.1.0" }, "devDependencies": { diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index c3718b10c4..7934489370 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -10,9 +10,11 @@ import { } from '@modrinth/api-client' import { ArrowBigUpDashIcon, + ArrowLeftRightIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, + ImagesIcon, LogInIcon, LogOutIcon, NewspaperIcon, @@ -23,20 +25,25 @@ import { ServerStackIcon, SettingsIcon, ShirtIcon, + SpinnerIcon, + ToggleRightIcon, UserIcon, + UserPlusIcon, + XIcon, } from '@modrinth/assets' import { + AccountSwitchOverlay, Admonition, Avatar, ButtonLink, commonMessages, + commonSettingsMessages, ContentInstallModal, ContentUpdaterModal, CreationFlowModal, defineMessages, I18nDebugPanel, IconButton, - IntlFormatted, LoadingBar, MarkdownBody, NewsArticleCard, @@ -52,17 +59,19 @@ import { useDebugLogger, useFormatBytes, useHostingIntercom, + UserRoleIcon, useVIntl, } from '@modrinth/ui' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { getVersion } from '@tauri-apps/api/app' import { convertFileSrc, invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { openUrl } from '@tauri-apps/plugin-opener' import { type } from '@tauri-apps/plugin-os' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' -import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue' +import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue' import { RouterView, useRoute, useRouter } from 'vue-router' import AccountsCard from '@/components/ui/AccountsCard.vue' @@ -96,8 +105,9 @@ 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 { useTheme } from '@/composables/use-theme.ts' +import { isDarkTheme, useTheme } from '@/composables/use-theme.ts' import { config } from '@/config' +import { getAccountAppearance, rememberAccountAppearance } from '@/helpers/account-appearance.ts' import { hide_ads_window, init_ads_window, @@ -108,14 +118,27 @@ import { } from '@/helpers/ads.js' import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' import { check_reachable } from '@/helpers/auth.js' -import { get_user, get_version } from '@/helpers/cache.js' +import { get_user, get_user_many, get_version } from '@/helpers/cache.js' import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' -import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance' -import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts' +import { + can_current_user_use_shared_instances, + get as getInstance, + get_global_synced_options, + run, +} from '@/helpers/instance' +import { + get as getCreds, + getAll as getAllCreds, + login, + logout, + removeUser, + 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 { get_opening_command, initialize_state } from '@/helpers/state' 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' import { areUpdatesEnabled, @@ -157,7 +180,10 @@ 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 { appSettingsModalOpenProfileKey } from './providers/app-settings-modal' +import { + appSettingsModalOpenProfileKey, + appSettingsModalOpenSyncedOptionsKey, +} from './providers/app-settings-modal' const appSettings = useAppSettings() const appTheme = useTheme() @@ -201,6 +227,7 @@ const APP_SIDEBAR_WIDTH = 300 const INTERCOM_BUBBLE_DEFAULT_PADDING = 20 const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime() const credentials = ref() +const storedModrinthAccounts = ref([]) let credentialsRefreshId = 0 const sidebarToggled = ref(true) watch( @@ -421,10 +448,20 @@ const os = ref('') const isDevEnvironment = ref(false) const stateInitialized = ref(false) +const globalSyncedOptionsQuery = useQuery({ + queryKey: ['global-synced-options'], + queryFn: get_global_synced_options, + enabled: computed(() => stateInitialized.value), +}) const criticalErrorMessage = ref() const isMaximized = ref(false) +const isFullscreen = ref(false) + +watch([os, isFullscreen], ([osName, fullscreen]) => { + document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen) +}) const authUnreachableDebug = useDebugLogger('AuthReachableChecker') const authServerQuery = useQuery({ @@ -447,7 +484,24 @@ const authUnreachable = computed(() => { return false }) +let unlistenEditMenu + +function handleEditMenuAction(action) { + const event = new CustomEvent(`edit-menu:${action}`, { cancelable: true }) + if (document.dispatchEvent(event)) document.execCommand(action) +} + onMounted(async () => { + try { + const listeners = await Promise.all([ + listen('edit-menu://undo', () => handleEditMenuAction('undo')), + listen('edit-menu://redo', () => handleEditMenuAction('redo')), + ]) + unlistenEditMenu = () => listeners.forEach((unlisten) => unlisten()) + } catch (error) { + handleError(error) + } + await useCheckDisableMouseover() try { handleAdsConsentRequired(await should_show_ads_consent_popup()) @@ -457,6 +511,7 @@ onMounted(async () => { document.querySelector('body').addEventListener('click', handleClick) document.querySelector('body').addEventListener('auxclick', handleAuxClick) + document.querySelector('body').addEventListener('contextmenu', handleContextMenu) document.addEventListener('fullscreenchange', handleFullscreenChange) checkUpdates() @@ -465,7 +520,9 @@ onMounted(async () => { onUnmounted(async () => { document.querySelector('body').removeEventListener('click', handleClick) document.querySelector('body').removeEventListener('auxclick', handleAuxClick) + document.querySelector('body').removeEventListener('contextmenu', handleContextMenu) document.removeEventListener('fullscreenchange', handleFullscreenChange) + unlistenEditMenu?.() clearDelayedUpdatePopup() if (fullscreenAdsWindowHold) { @@ -480,7 +537,6 @@ 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' }, @@ -534,6 +590,10 @@ const messages = defineMessages({ id: 'app.nav.modrinth-hosting', defaultMessage: 'Modrinth Hosting', }, + screenshots: { + id: 'app.nav.screenshots', + defaultMessage: 'Screenshots', + }, createNewInstance: { id: 'app.nav.create-new-instance', defaultMessage: 'Create new instance', @@ -542,13 +602,33 @@ const messages = defineMessages({ id: 'app.nav.modrinth-account', defaultMessage: 'Modrinth account', }, - signedInAs: { - id: 'app.nav.signed-in-as', - defaultMessage: 'Signed in as {username}', + viewProfile: { + id: 'app.nav.view-profile', + defaultMessage: 'View profile', + }, + addFriend: { + id: 'friends.action.add-friend', + defaultMessage: 'Add a friend', }, signInToModrinthAccount: { id: 'app.nav.sign-in-to-modrinth-account', - defaultMessage: 'Sign in to a Modrinth account', + defaultMessage: 'Sign into Modrinth', + }, + loadingProfile: { + id: 'app.nav.loading-profile', + defaultMessage: 'Loading profile...', + }, + switchAccount: { + id: 'app.nav.switch-account', + defaultMessage: 'Switch account', + }, + addAccount: { + id: 'app.nav.add-account', + defaultMessage: 'Add account', + }, + removeAccount: { + id: 'app.nav.remove-account', + defaultMessage: 'Remove account', }, restarting: { id: 'app.restarting', @@ -652,6 +732,9 @@ async function setupApp() { i18n.global.locale.value = locale } + Object.assign(appSettings.featureFlags, feature_flags) + isMaximized.value = await getCurrentWindow().isMaximized() + isFullscreen.value = await getCurrentWindow().isFullscreen() os.value = await getOS() const dev = await isDev() isDevEnvironment.value = dev @@ -666,13 +749,11 @@ async function setupApp() { appSettings.hideNametagSkinsPage = hide_nametag_skins_page appSettings.toggleSidebar = toggle_sidebar appSettings.devMode = developer_mode - Object.assign(appSettings.featureFlags, feature_flags) stateInitialized.value = true - isMaximized.value = await getCurrentWindow().isMaximized() - await getCurrentWindow().onResized(async () => { isMaximized.value = await getCurrentWindow().isMaximized() + isFullscreen.value = await getCurrentWindow().isFullscreen() }) if (telemetry) { @@ -681,8 +762,6 @@ async function setupApp() { trackEvent('Launched', { version, dev }) } - if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault()) - const osType = await type() if (osType === 'macos') { document.getElementsByTagName('html')[0].classList.add('mac') @@ -957,6 +1036,7 @@ const updateToPlayModal = ref() const modrinthLoginModal = ref() const appSettingsModal = ref() provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile()) +provide(appSettingsModalOpenSyncedOptionsKey, () => appSettingsModal.value?.showSyncedOptions()) watch(incompatibilityWarningModal, (modal) => { if (modal) { @@ -984,6 +1064,13 @@ watch( .then(async () => { const settings = await getSettings() const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme + const userId = credentials.value?.user_id ?? credentials.value?.user?.id + if (userId) { + rememberAccountAppearance(userId, preferences.appearance) + } + if (appTheme.syncAcrossDevices && isDarkTheme(preferences.appearance.theme)) { + appTheme.preferredDark = preferences.appearance.theme + } const locale = preferences.localization.locale const behavior = preferences.behavior let settingsChanged = false @@ -1073,10 +1160,12 @@ async function fetchCredentials() { if (refreshId !== credentialsRefreshId) return clearLiveNotifications() - await logout().catch(handleError) + await removeUser(creds.user_id).catch(handleError) if (refreshId !== credentialsRefreshId) return credentials.value = null + liveNotificationsEnabled = false + await fetchStoredModrinthAccounts() return } creds.user = await get_user(creds.user_id, 'bypass').catch(handleError) @@ -1084,11 +1173,12 @@ async function fetchCredentials() { } credentials.value = creds ?? null liveNotificationsEnabled = !!creds?.session + await fetchStoredModrinthAccounts() } -async function signIn(flow = 'sign-in') { +async function signIn(flow = 'sign-in', addAccount = false) { try { - await login(flow) + await login(flow, addAccount) await fetchCredentials() } catch (error) { if ( @@ -1096,35 +1186,190 @@ async function signIn(flow = 'sign-in') { typeof error['message'] === 'string' && error.message.includes('Login canceled') ) { - // Not really an error due to being a result of user interaction, show nothing + // user closed the login window } else { handleError(error) } } } -async function requestSignIn(flow = 'sign-in') { - await modrinthLoginModal.value?.showSigningIn(flow) +async function requestSignIn(flow = 'sign-in', addAccount = false) { + await modrinthLoginModal.value?.showSigningIn(flow, addAccount) } -async function requestModrinthAuth(flow = 'sign-in') { - await signIn(flow) +async function requestModrinthAuth(flow = 'sign-in', addAccount = false) { + await signIn(flow, addAccount) return !!credentials.value?.session } async function logOut() { - await performLogOut() + if (!credentials.value?.user) return + await completeAccountSwitch(() => logout()) } -async function performLogOut() { - credentialsRefreshId++ - credentials.value = undefined - clearLiveNotifications() +async function fetchStoredModrinthAccounts() { + const all = (await getAllCreds().catch(handleError)) ?? [] + const ids = all.map((account) => account.user_id) + const users = ids.length ? ((await get_user_many(ids).catch(handleError)) ?? []) : [] + const usersById = new Map(users.map((user) => [user.id, user])) - await logout().catch(handleError) - await fetchCredentials() + storedModrinthAccounts.value = all.map((account) => ({ + ...account, + user: usersById.get(account.user_id) ?? { + id: account.user_id, + username: account.user_id, + avatar_url: null, + role: 'developer', + }, + })) } +const accountSwitcherAccounts = computed(() => { + const currentId = credentials.value?.session ? credentials.value.user_id : null + + return storedModrinthAccounts.value.map((account) => ({ + ...account, + optionId: `account-${account.user_id}`, + current: account.user_id === currentId, + })) +}) + +const profileButtonTooltip = computed(() => { + if (credentials.value === undefined) return formatMessage(messages.loadingProfile) + if (credentials.value?.user) return formatMessage(messages.modrinthAccount) + return formatMessage(messages.signInToModrinthAccount) +}) + +const accountSwitcherOptions = computed(() => [ + ...accountSwitcherAccounts.value.map((account) => ({ + id: account.optionId, + label: account.user.username, + selected: account.current, + action: () => switchModrinthAccount(account), + trailingAction: { + label: formatMessage(messages.removeAccount), + icon: XIcon, + color: 'red', + action: () => forgetModrinthAccount(account.user_id), + }, + })), + { + type: 'divider', + }, + { + id: 'add-account', + label: formatMessage(messages.addAccount), + icon: PlusIcon, + action: () => requestSignIn('sign-in', true), + }, +]) + +const isSwitchingAccount = ref(false) + +async function persistAppearanceTheme(appearance) { + const selectedTheme = appearance.auto ? 'system' : appearance.theme + appTheme.applyAccountAppearance(appearance) + const settings = await getSettings() + if (settings.theme !== selectedTheme) { + settings.theme = selectedTheme + await setSettings(settings) + } +} + +async function completeAccountSwitch(task) { + isSwitchingAccount.value = true + await nextTick() + try { + await task() + window.location.reload() + } catch (error) { + isSwitchingAccount.value = false + handleError(error) + } +} + +async function switchModrinthAccount(account) { + if (account.current) return + + const cached = getAccountAppearance(account.user_id) + if (cached) await persistAppearanceTheme(cached) + await nextTick() + + await completeAccountSwitch(async () => { + await setActive(account.user_id) + if (cached) return + + try { + const preferences = await get_user_preferences(account.user_id) + rememberAccountAppearance(account.user_id, preferences.appearance) + await persistAppearanceTheme(preferences.appearance) + await nextTick() + } catch { + // no saved appearance for this account + } + }) +} + +async function forgetModrinthAccount(userId) { + const isCurrent = credentials.value?.user_id === userId && !!credentials.value?.session + if (isCurrent) { + await completeAccountSwitch(() => removeUser(userId)) + return + } + + await removeUser(userId).catch(handleError) + await fetchStoredModrinthAccounts() +} + +const modrinthAccountMenuOptions = computed(() => [ + { + id: 'view-profile', + label: formatMessage(messages.viewProfile), + icon: UserIcon, + action: () => router.push(`/user/${encodeURIComponent(credentials.value.user.username)}`), + }, + { + id: 'plus', + label: formatMessage(messages.upgradeToModrinthPlus), + icon: ArrowBigUpDashIcon, + type: 'link', + href: 'https://modrinth.plus?app', + target: '_blank', + tone: 'purple', + shown: !hasPlus.value, + }, + { + id: 'add-friend', + label: formatMessage(messages.addFriend), + icon: UserPlusIcon, + action: () => friendsList.value?.showAddFriendModal(), + }, + { + id: 'flags', + label: formatMessage(commonSettingsMessages.featureFlags), + icon: ToggleRightIcon, + shown: appSettings.devMode, + action: () => appSettingsModal.value?.showFeatureFlags(), + }, + { + type: 'divider', + }, + { + id: 'switch-account', + label: formatMessage(messages.switchAccount), + icon: ArrowLeftRightIcon, + type: 'submenu', + options: accountSwitcherOptions.value, + }, + { + id: 'sign-out', + label: formatMessage(commonMessages.signOutButton), + icon: LogOutIcon, + tone: 'red', + action: () => logOut(), + }, +]) + async function fetchIntercomToken() { const creds = await getCreds() if (!creds?.session) { @@ -1184,6 +1429,7 @@ onMounted(() => { }) const accounts = ref(null) +const friendsList = ref(null) provide('accountsCard', accounts) useAppEvent('command', handleCommand, appEvents) @@ -1711,12 +1957,39 @@ function handleAuxClick(e) { } } +function handleContextMenu(event) { + const target = event.target + if (target instanceof Element) { + if (target.closest('img, textarea, [contenteditable="true"]')) return + const input = target.closest('input') + if ( + input && + !['button', 'checkbox', 'radio', 'submit', 'reset', 'file', 'range'].includes(input.type) + ) { + return + } + } + + const selection = window.getSelection() + if ( + target instanceof Node && + selection && + !selection.isCollapsed && + selection.containsNode(target, true) + ) { + return + } + + event.preventDefault() +} + provideAppUpdateDownloadProgress(appUpdateDownload) + + diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue index 8c0c4126c3..35d81cabc0 100644 --- a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue @@ -1,6 +1,6 @@ diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index b53e5acc01..410a9a52bc 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -208,15 +208,9 @@ v-if="projectInstallContext" :install-context="projectInstallContext" /> - - - - - diff --git a/apps/frontend/src/pages/admin/affiliates.vue b/apps/frontend/src/pages/admin/affiliates.vue index 7d2db99a25..097963108c 100644 --- a/apps/frontend/src/pages/admin/affiliates.vue +++ b/apps/frontend/src/pages/admin/affiliates.vue @@ -13,11 +13,9 @@ :proceed-label="`Revoke`" @proceed="confirmRevokeAffiliateCode" /> -
-
-

Manage affiliate links

+
+
+

Manage affiliate links

- - diff --git a/apps/frontend/src/pages/admin/analytics/events.vue b/apps/frontend/src/pages/admin/analytics/events.vue index ddfb40773e..81b05716e0 100644 --- a/apps/frontend/src/pages/admin/analytics/events.vue +++ b/apps/frontend/src/pages/admin/analytics/events.vue @@ -125,108 +125,105 @@ -
-
-
-

Analytics Events

+
+
+

Analytics events

+
+ + +
+
-
- -
@@ -241,10 +238,12 @@ import { SpinnerIcon, TrashIcon, } from '@modrinth/assets' -import { Button, ButtonLink, IconButton } from '@modrinth/ui' import { + Button, + ButtonLink, ConfirmModal, DatePicker, + IconButton, injectModrinthClient, injectNotificationManager, Input, diff --git a/apps/frontend/src/pages/admin/billing/[user].vue b/apps/frontend/src/pages/admin/billing/[user].vue index 789b57b095..8e6141f1ef 100644 --- a/apps/frontend/src/pages/admin/billing/[user].vue +++ b/apps/frontend/src/pages/admin/billing/[user].vue @@ -120,13 +120,11 @@
-
-
+
+
-

{{ user?.username }}'s subscriptions

+

{{ user?.username }}'s subscriptions

@@ -210,7 +208,6 @@ import { Button, ButtonLink, CopyCode, - defineMessages, DropdownSelect, injectModrinthClient, injectNotificationManager, @@ -219,7 +216,6 @@ import { Toggle, useFormatDateTime, useRelativeTime, - useVIntl, } from '@modrinth/ui' import { capitalizeString } from '@modrinth/utils' import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts' @@ -236,18 +232,8 @@ const formatDateTime = useFormatDateTime({ dateStyle: 'long', }) -const vintl = useVIntl() - -const { formatMessage } = vintl const formatRelativeTime = useRelativeTime() -const messages = defineMessages({ - userNotFoundError: { - id: 'admin.billing.error.not-found', - defaultMessage: 'User not found', - }, -}) - const userId = useRouteId('user') const { @@ -266,7 +252,7 @@ watch(userError, (error) => { showError({ fatal: true, statusCode: error.statusCode ?? error.status ?? 404, - message: formatMessage(messages.userNotFoundError), + message: 'User not found', }) } }) @@ -410,11 +396,3 @@ async function modifyCharge() { modifying.value = false } - diff --git a/apps/frontend/src/pages/admin/docs.vue b/apps/frontend/src/pages/admin/docs.vue index 33d519348a..d77b8a932e 100644 --- a/apps/frontend/src/pages/admin/docs.vue +++ b/apps/frontend/src/pages/admin/docs.vue @@ -1,6 +1,6 @@