diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml index 703431d240..79b73c57ef 100644 --- a/.github/workflows/frontend-deploy.yml +++ b/.github/workflows/frontend-deploy.yml @@ -125,8 +125,7 @@ jobs: run: pnpm install - name: Build frontend - working-directory: ./apps/frontend - run: pnpm build + run: pnpm web:build env: CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }} CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }} diff --git a/.github/workflows/frontend-docker.yml b/.github/workflows/frontend-docker.yml new file mode 100644 index 0000000000..cd63875f11 --- /dev/null +++ b/.github/workflows/frontend-docker.yml @@ -0,0 +1,99 @@ +name: Frontend Docker + +on: + push: + branches: + - main + - prod + paths: + - 'apps/frontend/**/*' + - 'packages/ui/**/*' + - 'packages/utils/**/*' + - 'packages/assets/**/*' + - 'packages/moderation/**/*' + - 'packages/api-client/**/*' + - 'packages/blog/**/*' + - '**/pnpm-*.yaml' + - .github/workflows/frontend-docker.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }} + +jobs: + build: + name: Build frontend + runs-on: namespace-profile-modrinth-frontend + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Configure environment + id: meta + env: + REF: ${{ github.ref }} + run: | + if [ "$REF" = "refs/heads/main" ]; then + echo "env=staging" >> $GITHUB_OUTPUT + else + echo "env=production" >> $GITHUB_OUTPUT + fi + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + + - name: Enable Corepack + run: corepack enable + + - name: Set up caches + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + cache: | + pnpm + + - name: Inject build variables + working-directory: ./apps/frontend + run: | + if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then + echo "Injecting staging variables from wrangler.jsonc..." + jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV + else + echo "Injecting production variables from wrangler.jsonc..." + jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV + fi + + - name: Install dependencies + working-directory: ./apps/frontend + run: pnpm install + + - name: Build frontend + run: pnpm web:build + env: + NITRO_PRESET: node-server + BUILD_ENV: ${{ steps.meta.outputs.env }} + CF_PAGES_BRANCH: ${{ github.ref_name }} + CF_PAGES_COMMIT_SHA: ${{ github.sha }} + + - name: Stage Docker context + run: | + mkdir -p apps/frontend/docker-stage + cp -r apps/frontend/.output apps/frontend/docker-stage/.output + + - name: Upload Docker context + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 + with: + name: frontend-docker-context + retention-days: 1 + path: apps/frontend/docker-stage + + docker-build: + needs: [build] + uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main + with: + image-name: frontend + dockerfile-path: apps/frontend/Dockerfile + artifacts-name: frontend-docker-context diff --git a/.github/workflows/frontend-preview.yml b/.github/workflows/frontend-preview.yml index df17e1786c..16e76136fd 100644 --- a/.github/workflows/frontend-preview.yml +++ b/.github/workflows/frontend-preview.yml @@ -70,10 +70,9 @@ jobs: run: pnpm install - name: Build Storybook - working-directory: ./packages/ui env: NODE_OPTIONS: --max-old-space-size=6144 - run: pnpm run build-storybook + run: pnpm build-storybook - name: Configure short SHA id: meta diff --git a/.github/workflows/i18n-pull.yml b/.github/workflows/i18n-pull.yml index df5ceffa58..185f7815e6 100644 --- a/.github/workflows/i18n-pull.yml +++ b/.github/workflows/i18n-pull.yml @@ -8,6 +8,10 @@ on: concurrency: group: i18n-management +permissions: + contents: write + pull-requests: write + jobs: pull_translations: name: 'Pull translations from Crowdin' @@ -38,23 +42,17 @@ jobs: flight_failure "CROWDIN_PERSONAL_TOKEN secret is not defined (required to push)" fi - if [ "$CROWDIN_GH_TOKEN_DEFINED" != true ]; then - flight_failure "CROWDIN_GH_TOKEN secret is not defined (required to make pull requests)" - fi - if [ "$PREFLIGHT_CHECK_RESULT" = false ]; then exit 1 fi env: CROWDIN_PROJECT_ID_DEFINED: ${{ vars.CROWDIN_PROJECT_ID != '' }} CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }} - CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }} - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.ref }} - token: ${{ secrets.CROWDIN_GH_TOKEN }} - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -67,12 +65,6 @@ jobs: - name: Install script dependencies run: pnpm install --frozen-lockfile --ignore-scripts - - name: Configure Git author - id: git-author - uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2 - env: - GITHUB_TOKEN: ${{ secrets.CROWDIN_GH_TOKEN }} - # # Because --all flag of Crowdin CLI is currently broken we need to create a fake source file # # so that the CLI won't omit translations for it. See https://github.com/crowdin/crowdin-cli/issues/724 # - name: Write fake sources @@ -116,7 +108,4 @@ jobs: body-path: .github/templates/crowdin-pr.md commit-message: 'New translations from Crowdin (${{ steps.branch-name.outputs.branch_name }})' branch: crowdin-pull/${{ steps.branch-name.outputs.branch_name }} - author: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>' - committer: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>' labels: sync - token: ${{ secrets.CROWDIN_GH_TOKEN }} diff --git a/.gitignore b/.gitignore index 9d27dcaf5e..d442251099 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ Thumbs.db # frontend generated files apps/frontend/src/generated +/packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts .turbo target diff --git a/Cargo.lock b/Cargo.lock index 99ec14bd94..5b10537334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2730,10 +2730,10 @@ dependencies = [ "futures", "indexmap 2.11.4", "itertools 0.14.0", + "quick-xml 0.38.3", "reqwest 0.12.24", "rust-s3", "serde", - "serde-xml-rs", "serde_json", "sha1_smol", "thiserror 2.0.17", @@ -9503,18 +9503,6 @@ dependencies = [ "typeid", ] -[[package]] -name = "serde-xml-rs" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510" -dependencies = [ - "log", - "serde", - "thiserror 1.0.69", - "xml-rs", -] - [[package]] name = "serde_bytes" version = "0.11.19" diff --git a/Cargo.toml b/Cargo.toml index 36aa6975df..a1fa25deea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -193,7 +193,6 @@ serde_cbor = "0.11.2" serde_ini = "0.2.0" serde_json = "1.0.145" serde_with = "3.15.0" -serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this sha1 = "0.10.6" sha1_smol = { version = "1.0.1", features = ["std"] } sha2 = "0.10.9" diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 5a88bd2d92..abe6e55c0f 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -38,7 +38,6 @@ "intl-messageformat": "^10.7.7", "ofetch": "^1.3.4", "overlayscrollbars": "^2.15.1", - "pinia": "^3.0.0", "posthog-js": "^1.158.2", "three": "^0.172.0", "vite-svg-loader": "^5.1.0", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index eeb37273f6..33afd4d216 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -94,6 +94,9 @@ import SurveyPopup from '@/components/ui/SurveyPopup.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 { useTheme } from '@/composables/use-theme.ts' import { config } from '@/config' import { hide_ads_window, @@ -147,8 +150,7 @@ import { setupProviders } from '@/providers/setup' import { setupAppEventsProvider } from '@/providers/setup/app-events' import { setupAuthProvider } from '@/providers/setup/auth' import { setupLoadingStateProvider } from '@/providers/setup/loading-state' -import { useError } from '@/store/error.js' -import { useTheming } from '@/store/state' +import { setupAppUserPreferencesProvider } from '@/providers/setup/user-preferences.ts' import { appMessages } from '@/utils/app-messages' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' @@ -157,7 +159,8 @@ import { AppNotificationManager } from './providers/app-notifications' import { AppPopupNotificationManager } from './providers/app-popup-notifications' import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal' -const themeStore = useTheming() +const appSettings = useAppSettings() +const appTheme = useTheme() const router = useRouter() const route = useRoute() const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider() @@ -201,7 +204,7 @@ const credentials = ref() let credentialsRefreshId = 0 const sidebarToggled = ref(true) watch( - () => themeStore.toggleSidebar, + () => appSettings.toggleSidebar, (toggleSidebar) => { sidebarToggled.value = !toggleSidebar }, @@ -221,7 +224,7 @@ const hostingUpdateRequired = computed( appUpdateState.updatesEnabled.value, ) const prideFundraiserEnabled = computed( - () => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE, + () => appSettings.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE, ) const hostingIntercomIdentityKey = computed(() => { const rawServerId = route.params.id @@ -324,13 +327,13 @@ providePageContext({ intercomBubble: hostingIntercom.intercomBubble, featureFlags: { serverRamAsBytesAlwaysOn: computed(() => - themeStore.getFeatureFlag('server_ram_as_bytes_always_on'), + appSettings.getFeatureFlag('server_ram_as_bytes_always_on'), ), }, openExternalUrl: (url) => void openUrl(url), }) provideModalBehavior({ - noblur: computed(() => !themeStore.advancedRendering), + noblur: computed(() => !appTheme.advancedRendering), onShow: () => take_ads_window_hold(), onHide: () => release_ads_window_hold(), }) @@ -634,10 +637,11 @@ async function setupApp() { theme, locale, telemetry, - collapsed_navigation, hide_nametag_skins_page, advanced_rendering, toggle_sidebar, + sync_theme_across_devices, + sync_behavior_across_devices, developer_mode, feature_flags, pending_update_toast_for_version, @@ -655,13 +659,14 @@ async function setupApp() { nativeDecorations.value = native_decorations if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations) - themeStore.setThemeState(theme) - themeStore.collapsedNavigation = collapsed_navigation - themeStore.advancedRendering = advanced_rendering - themeStore.hideNametagSkinsPage = hide_nametag_skins_page - themeStore.toggleSidebar = toggle_sidebar - themeStore.devMode = developer_mode - themeStore.featureFlags = feature_flags + appTheme.preferred = theme + appTheme.advancedRendering = advanced_rendering + appTheme.syncAcrossDevices = sync_theme_across_devices + appSettings.syncBehaviorAcrossDevices = sync_behavior_across_devices + 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() @@ -959,7 +964,7 @@ watch(incompatibilityWarningModal, (modal) => { } }) -setupAuthProvider(credentials, async (_redirectPath, flow, options) => { +const authProvider = setupAuthProvider(credentials, async (_redirectPath, flow, options) => { if (options?.showModal === false) { await signIn(flow) } else { @@ -967,6 +972,80 @@ setupAuthProvider(credentials, async (_redirectPath, flow, options) => { } }) +const userPreferences = setupAppUserPreferencesProvider(authProvider, notificationManager) +let userPreferencesSync = Promise.resolve() + +watch( + [userPreferences.preferences, stateInitialized], + ([preferences, initialized]) => { + if (!preferences || !initialized) return + + userPreferencesSync = userPreferencesSync + .then(async () => { + const settings = await getSettings() + const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme + const locale = preferences.localization.locale + const behavior = preferences.behavior + let settingsChanged = false + + if (appTheme.syncAcrossDevices && appTheme.preferred !== selectedTheme) { + appTheme.preferred = selectedTheme + } + if (i18n.global.locale.value !== locale) { + i18n.global.locale.value = locale + } + + if (appTheme.syncAcrossDevices && settings.theme !== selectedTheme) { + settings.theme = selectedTheme + settingsChanged = true + } + if (settings.locale !== locale) { + settings.locale = locale + settingsChanged = true + } + + 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, + skip_non_essential_warnings: behavior.skip_non_essential_warnings, + } + + appSettings.toggleSidebar = behavior.hide_right_sidebar + appSettings.hideNametagSkinsPage = behavior.hide_nametag + Object.assign(appSettings.featureFlags, behaviorFeatureFlags) + + if (settings.hide_on_process_start !== behavior.minimize_app) { + settings.hide_on_process_start = behavior.minimize_app + settingsChanged = true + } + if (settings.toggle_sidebar !== behavior.hide_right_sidebar) { + settings.toggle_sidebar = behavior.hide_right_sidebar + settingsChanged = true + } + if (settings.hide_nametag_skins_page !== behavior.hide_nametag) { + settings.hide_nametag_skins_page = behavior.hide_nametag + settingsChanged = true + } + for (const [flag, value] of Object.entries(behaviorFeatureFlags)) { + if (settings.feature_flags[flag] !== value) { + settings.feature_flags[flag] = value + settingsChanged = true + } + } + } + + if (settingsChanged) { + await setSettings(settings) + } + }) + .catch(handleError) + }, + { immediate: true }, +) + async function validateSession(sessionToken) { try { const response = await tauriFetch(`${config.labrinthBaseUrl}/v2/user`, { @@ -1523,9 +1602,8 @@ async function downloadUpdate(versionToDownload) { .then(() => { downloading.value = false finishedDownloading.value = true - unlistenUpdateDownload?.().then(() => { - unlistenUpdateDownload = null - }) + unlistenUpdateDownload?.() + unlistenUpdateDownload = null console.log('Finished downloading!') markAppUpdateActionable(versionToDownload.version, 'downloaded') scheduleDelayedUpdatePopup() @@ -1642,7 +1720,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -1867,7 +1945,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
{{ route.fullPath }} diff --git a/apps/app-frontend/src/assets/instance-icons/fabric.png b/apps/app-frontend/src/assets/instance-icons/fabric.png new file mode 100644 index 0000000000..c763735e73 Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/fabric.png differ diff --git a/apps/app-frontend/src/assets/instance-icons/forge.png b/apps/app-frontend/src/assets/instance-icons/forge.png new file mode 100644 index 0000000000..09f54201e8 Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/forge.png differ diff --git a/apps/app-frontend/src/assets/instance-icons/neoforge.png b/apps/app-frontend/src/assets/instance-icons/neoforge.png new file mode 100644 index 0000000000..f82d367d38 Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/neoforge.png differ diff --git a/apps/app-frontend/src/assets/instance-icons/quilt.png b/apps/app-frontend/src/assets/instance-icons/quilt.png new file mode 100644 index 0000000000..a829f58a44 Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/quilt.png differ diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index ac9136f6c2..6dacd0f6a2 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -20,6 +20,7 @@
- +

-

-
-
-
- -
-
-
- - - - - - diff --git a/apps/app-frontend/src/components/ui/ErrorModal.vue b/apps/app-frontend/src/components/ui/ErrorModal.vue index 475c1f3f76..efac61720c 100644 --- a/apps/app-frontend/src/components/ui/ErrorModal.vue +++ b/apps/app-frontend/src/components/ui/ErrorModal.vue @@ -20,11 +20,11 @@ import { computed, ref } from 'vue' import { ChatIcon } from '@/assets/icons' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' +import { handleSevereError } from '@/composables/use-error.js' import { trackEvent } from '@/helpers/analytics' import { login as login_flow, set_default_user } from '@/helpers/auth.js' import { install_existing_instance } from '@/helpers/install' import { cancel_directory_change } from '@/helpers/settings.ts' -import { handleSevereError } from '@/store/error.js' const { handleError } = injectNotificationManager() diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue index 2bc32b67fc..9f8338df6d 100644 --- a/apps/app-frontend/src/components/ui/Instance.vue +++ b/apps/app-frontend/src/components/ui/Instance.vue @@ -20,12 +20,12 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useAppEvent } from '@/composables/use-app-event' +import { handleSevereError } from '@/composables/use-error.js' import { trackEvent } from '@/helpers/analytics' import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install' 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() diff --git a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue index 0bbabbf9c1..8dc9a0e44b 100644 --- a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue +++ b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue @@ -11,7 +11,7 @@ import { getInstanceIconUrl, list } from '@/helpers/instance' import { instanceKeys } from '@/pages/instance/query-options' const ITEM_SIZE = 52 -const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow +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() diff --git a/apps/app-frontend/src/components/ui/WindowControls.vue b/apps/app-frontend/src/components/ui/WindowControls.vue index 81797ba5fa..a10ccffff5 100644 --- a/apps/app-frontend/src/components/ui/WindowControls.vue +++ b/apps/app-frontend/src/components/ui/WindowControls.vue @@ -39,17 +39,17 @@ import { getCurrentWindow } from '@tauri-apps/api/window' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' import { computed, onMounted, onUnmounted, ref } from 'vue' +import { useAppSettings } from '@/composables/use-app-settings.ts' import { get as getSettings } from '@/helpers/settings.ts' import { getOS } from '@/helpers/utils.js' -import { useTheming } from '@/store/state' -const themeStore = useTheming() +const appSettings = useAppSettings() const nativeDecorations = ref(true) const isMaximized = ref(false) const os = ref('') -const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls')) +const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls')) const showControls = computed( () => diff --git a/apps/app-frontend/src/components/ui/context-menu/index.vue b/apps/app-frontend/src/components/ui/context-menu/index.vue new file mode 100644 index 0000000000..93732168bb --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/index.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/context-menu/types.ts b/apps/app-frontend/src/components/ui/context-menu/types.ts new file mode 100644 index 0000000000..fe45d2b6ff --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/types.ts @@ -0,0 +1,41 @@ +import type { ComponentPublicInstance } from 'vue' + +export type ContextMenuDivider = { + type: string +} + +export type ContextMenuAction = { + name: string + color?: string + children?: ContextMenuOption[] +} + +export type ContextMenuParentAction = ContextMenuAction & { + children: ContextMenuOption[] +} + +export type ContextMenuOption = ContextMenuDivider | ContextMenuAction + +export type ContextMenuSelection = { + item: unknown + option: string +} + +export type ContextMenuEmit = { + (event: 'menu-closed'): void + (event: 'option-clicked', selection: ContextMenuSelection): void +} + +export type Point = { + x: number + y: number +} + +export type ViewportRect = { + width: number + height: number + offsetTop: number + offsetLeft: number +} + +export type ButtonRefElement = Element | ComponentPublicInstance | null diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts new file mode 100644 index 0000000000..7ffa4fef4f --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts @@ -0,0 +1,170 @@ +import type { CSSProperties, Ref } from 'vue' +import { computed, nextTick, ref } from 'vue' + +import type { ContextMenuParentAction, Point, ViewportRect } from './types' + +const MENU_GAP = 8 +const VIEWPORT_MARGIN = 10 + +type ContextMenuPositionOptions = { + shown: Ref + activeOption: Ref + activeOptionIndex: Ref + isMobileSubmenuLayout: Ref + contextMenu: Ref + submenu: Ref + optionButtonRefs: Map +} + +export function useContextMenuPosition({ + shown, + activeOption, + activeOptionIndex, + isMobileSubmenuLayout, + contextMenu, + submenu, + optionButtonRefs, +}: ContextMenuPositionOptions) { + const menuStyle = ref({ left: '0px', top: '0px' }) + const menuAnchor = ref({ x: 0, y: 0 }) + const submenuPosition = ref({ x: 0, y: 0 }) + const hasSubmenuPosition = ref(false) + let positionRafId: number | null = null + + const submenuStyle = computed(() => { + if (isMobileSubmenuLayout.value) return menuStyle.value + + return { + left: `${submenuPosition.value.x}px`, + top: `${submenuPosition.value.y}px`, + } + }) + + function updateMenuPosition(x: number, y: number) { + if (!contextMenu.value) return + + const viewport = getViewportRect() + const menuRect = contextMenu.value.getBoundingClientRect() + const viewportLeft = viewport.offsetLeft + const viewportTop = viewport.offsetTop + const viewportRight = viewport.offsetLeft + viewport.width + const viewportBottom = viewport.offsetTop + viewport.height + const anchorX = x + viewport.offsetLeft + const anchorY = y + viewport.offsetTop + const left = Math.min( + Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP), + Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN), + ) + const top = Math.min( + Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP), + Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN), + ) + + menuStyle.value = { left: `${left}px`, top: `${top}px` } + if (activeOption.value) scheduleSubmenuPositionUpdate() + } + + function updateSubmenuPosition() { + if (!activeOption.value || activeOptionIndex.value === null) return false + + if (isMobileSubmenuLayout.value) { + hasSubmenuPosition.value = true + return true + } + + const optionButton = optionButtonRefs.get(activeOptionIndex.value) + if (!optionButton || !contextMenu.value) return false + + const viewport = getViewportRect() + const buttonRect = optionButton.getBoundingClientRect() + const menuRect = contextMenu.value.getBoundingClientRect() + const submenuRect = submenu.value?.getBoundingClientRect() + const submenuWidth = submenuRect?.width ?? menuRect.width + const submenuHeight = submenuRect?.height ?? 100 + const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport) + const preferredLeft = + direction === 'right' + ? buttonRect.right + MENU_GAP + : buttonRect.left - submenuWidth - MENU_GAP + const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN + const maxLeft = Math.max( + minLeft, + viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN, + ) + const minTop = viewport.offsetTop + VIEWPORT_MARGIN + const maxTop = Math.max( + minTop, + viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN, + ) + + submenuPosition.value = { + x: Math.min(Math.max(minLeft, preferredLeft), maxLeft), + y: Math.min(Math.max(minTop, buttonRect.top), maxTop), + } + hasSubmenuPosition.value = true + return true + } + + function scheduleSubmenuPositionUpdate(retries = 8) { + nextTick(() => { + if (!shown.value || !activeOption.value) return + + const hasRenderedSubmenu = submenu.value !== null + if (updateSubmenuPosition()) { + if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition) + return + } + + if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0) + }) + } + + function schedulePositionUpdate() { + if (!shown.value || positionRafId !== null) return + + positionRafId = window.requestAnimationFrame(() => { + positionRafId = null + updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y) + }) + } + + function cancelPositionUpdate() { + if (positionRafId !== null) window.cancelAnimationFrame(positionRafId) + } + + return { + menuStyle, + menuAnchor, + submenuStyle, + hasSubmenuPosition, + updateMenuPosition, + scheduleSubmenuPositionUpdate, + schedulePositionUpdate, + cancelPositionUpdate, + } +} + +function getViewportRect(): ViewportRect { + const visualViewport = window.visualViewport + return { + width: visualViewport?.width ?? window.innerWidth, + height: visualViewport?.height ?? window.innerHeight, + offsetTop: visualViewport?.offsetTop ?? 0, + offsetLeft: visualViewport?.offsetLeft ?? 0, + } +} + +function getSubmenuOpenDirection( + menuRect: DOMRect, + submenuWidth: number, + viewport: ViewportRect, +): 'left' | 'right' { + const viewportLeft = viewport.offsetLeft + const viewportRight = viewport.offsetLeft + viewport.width + const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN + const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN + + if (rightSpace >= submenuWidth) return 'right' + if (leftSpace >= submenuWidth) return 'left' + return rightSpace >= leftSpace ? 'right' : 'left' +} diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts new file mode 100644 index 0000000000..a74b5012e0 --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts @@ -0,0 +1,357 @@ +import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' + +import type { + ButtonRefElement, + ContextMenuAction, + ContextMenuEmit, + ContextMenuOption, + ContextMenuParentAction, + Point, +} from './types' +import { useContextMenuPosition } from './use-context-menu-position' +import { + focusRelativeButton, + getFocusableButtons, + hasChildren, + isAction, + isInstanceLink, + isPointInTriangle, +} from './utils' + +const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)' +const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open' + +export function useContextMenu(emit: ContextMenuEmit) { + const item = ref(null) + const contextMenu = ref(null) + const submenu = ref(null) + const options = ref([]) + const shown = ref(false) + const activeOptionIndex = ref(null) + const pendingOptionIndex = ref(null) + const isCursorInsideSubmenu = ref(false) + const isMobileSubmenuLayout = ref(false) + const lastMousePosition = ref(null) + const contextMenuId = Symbol() + const optionButtonRefs = new Map() + const submenuButtonRefs = new Map() + let previousMousePosition: Point | null = null + let pendingOptionTimeout: ReturnType | null = null + let mobileSubmenuMediaQuery: MediaQueryList | null = null + + const activeOption = computed(() => { + if (activeOptionIndex.value === null) return null + const option = options.value[activeOptionIndex.value] + return option && isAction(option) && hasChildren(option) ? option : null + }) + + const { + menuStyle, + menuAnchor, + submenuStyle, + hasSubmenuPosition, + updateMenuPosition, + scheduleSubmenuPositionUpdate, + schedulePositionUpdate, + cancelPositionUpdate, + } = useContextMenuPosition({ + shown, + activeOption, + activeOptionIndex, + isMobileSubmenuLayout, + contextMenu, + submenu, + optionButtonRefs, + }) + + const isMobileActiveSubmenu = computed( + () => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value, + ) + + function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction { + return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content') + } + + function setOptionButtonRef(index: number, element: ButtonRefElement) { + setButtonRef(optionButtonRefs, index, element) + } + + function setSubmenuButtonRef(index: number, element: ButtonRefElement) { + setButtonRef(submenuButtonRefs, index, element) + } + + function hideMenu() { + if (!shown.value) return + + shown.value = false + deactivateSubmenu() + emit('menu-closed') + } + + function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) { + window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId })) + + item.value = passedItem + options.value = passedOptions + menuAnchor.value = { x: event.clientX, y: event.clientY } + shown.value = true + deactivateSubmenu() + syncMobileSubmenuLayout() + nextTick(() => updateMenuPosition(event.clientX, event.clientY)) + } + + function handleOptionClick(option: ContextMenuAction, index: number) { + if (hasChildren(option)) { + activateSubmenu(index) + return + } + + optionClicked(option.name) + } + + function optionClicked(option: string) { + emit('option-clicked', { item: item.value, option }) + hideMenu() + } + + function handleOptionFocus(option: ContextMenuAction, index: number) { + if (hasChildren(option) && !isMobileSubmenuLayout.value) { + activateSubmenu(index) + } else if (!hasChildren(option)) { + deactivateSubmenu() + } + } + + function handleOptionMouseEnter(option: ContextMenuAction, index: number) { + if (isMobileSubmenuLayout.value) return + + if (activeOptionIndex.value === null) { + if (hasChildren(option)) activateSubmenu(index) + return + } + + if (activeOptionIndex.value === index) return + + if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) { + commitHoveredOption(index) + return + } + + pendingOptionIndex.value = index + clearPendingOptionTimeout() + pendingOptionTimeout = setTimeout(() => { + if (pendingOptionIndex.value !== index) return + if (isCursorInsideSubmenu.value) { + pendingOptionIndex.value = null + return + } + + commitHoveredOption(index) + }, 180) + } + + function commitHoveredOption(index: number) { + const option = options.value[index] + if (option && hasChildren(option)) { + activateSubmenu(index) + } else { + deactivateSubmenu() + } + } + + function activateSubmenu(index: number) { + clearPendingOptionTimeout() + pendingOptionIndex.value = null + activeOptionIndex.value = index + hasSubmenuPosition.value = false + scheduleSubmenuPositionUpdate() + } + + function deactivateSubmenu() { + clearPendingOptionTimeout() + activeOptionIndex.value = null + pendingOptionIndex.value = null + hasSubmenuPosition.value = false + isCursorInsideSubmenu.value = false + lastMousePosition.value = null + previousMousePosition = null + } + + function returnToMenu() { + const previousIndex = activeOptionIndex.value + deactivateSubmenu() + nextTick(() => { + if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus() + }) + } + + function handleSubmenuMouseEnter() { + isCursorInsideSubmenu.value = true + clearPendingOptionTimeout() + pendingOptionIndex.value = null + } + + function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') { + previousMousePosition = lastMousePosition.value + lastMousePosition.value = { x: event.clientX, y: event.clientY } + + if ( + source === 'menu' && + pendingOptionIndex.value !== null && + !isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition) + ) { + commitHoveredOption(pendingOptionIndex.value) + } + } + + function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) { + const submenuRect = submenu.value?.getBoundingClientRect() + if (!submenuRect || !cursor || !origin) return false + + const submenuTargetX = + origin.x <= submenuRect.left + ? submenuRect.left + : origin.x >= submenuRect.right + ? submenuRect.right + : cursor.x <= submenuRect.left + ? submenuRect.left + : submenuRect.right + const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 } + const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 } + + return isPointInTriangle(cursor, origin, upperTarget, lowerTarget) + } + + function handleMenuKeydown(event: KeyboardEvent) { + const buttons = getFocusableButtons(optionButtonRefs) + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1) + } else if (event.key === 'Home' || event.key === 'End') { + event.preventDefault() + buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus() + } else if (event.key === 'ArrowRight') { + const focusedIndex = [...optionButtonRefs.entries()].find( + ([, button]) => button === document.activeElement, + )?.[0] + const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex] + if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) { + event.preventDefault() + activateSubmenu(focusedIndex) + nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus()) + } + } else if (event.key === 'Escape') { + event.preventDefault() + hideMenu() + } + } + + function handleSubmenuKeydown(event: KeyboardEvent) { + const buttons = getFocusableButtons(submenuButtonRefs) + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1) + } else if (event.key === 'Home' || event.key === 'End') { + event.preventDefault() + buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus() + } else if (event.key === 'ArrowLeft') { + event.preventDefault() + returnToMenu() + } else if (event.key === 'Escape') { + event.preventDefault() + hideMenu() + } + } + + function syncMobileSubmenuLayout(event?: MediaQueryListEvent) { + isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false + } + + function handleDocumentKeydown(event: KeyboardEvent) { + if (shown.value && event.key === 'Escape') hideMenu() + } + + function handleContextMenuOpen(event: Event) { + if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu() + } + + function handleClickOutside(event: MouseEvent) { + const target = event.target + if (!(target instanceof Node)) return + if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu() + } + + onMounted(() => { + mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY) + syncMobileSubmenuLayout() + mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout) + window.addEventListener('click', handleClickOutside) + window.addEventListener('resize', schedulePositionUpdate) + window.addEventListener('scroll', schedulePositionUpdate, true) + window.visualViewport?.addEventListener('scroll', schedulePositionUpdate) + window.visualViewport?.addEventListener('resize', schedulePositionUpdate) + window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen) + document.addEventListener('keydown', handleDocumentKeydown) + }) + + onBeforeUnmount(() => { + clearPendingOptionTimeout() + cancelPositionUpdate() + mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout) + window.removeEventListener('click', handleClickOutside) + window.removeEventListener('resize', schedulePositionUpdate) + window.removeEventListener('scroll', schedulePositionUpdate, true) + window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate) + window.visualViewport?.removeEventListener('resize', schedulePositionUpdate) + window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen) + document.removeEventListener('keydown', handleDocumentKeydown) + }) + + function clearPendingOptionTimeout() { + if (!pendingOptionTimeout) return + clearTimeout(pendingOptionTimeout) + pendingOptionTimeout = null + } + + return { + shown, + contextMenu, + submenu, + options, + menuStyle, + submenuStyle, + activeOption, + activeOptionIndex, + pendingOptionIndex, + hasSubmenuPosition, + isMobileSubmenuLayout, + isMobileActiveSubmenu, + isCursorInsideSubmenu, + isOptionVisible, + setOptionButtonRef, + setSubmenuButtonRef, + showMenu, + hideMenu, + handleOptionClick, + optionClicked, + handleOptionFocus, + handleOptionMouseEnter, + returnToMenu, + handleSubmenuMouseEnter, + handleMenuMouseMove, + handleMenuKeydown, + handleSubmenuKeydown, + } +} + +function setButtonRef( + buttonRefs: Map, + index: number, + element: ButtonRefElement, +) { + if (element instanceof HTMLElement) { + buttonRefs.set(index, element) + } else { + buttonRefs.delete(index) + } +} diff --git a/apps/app-frontend/src/components/ui/context-menu/utils.ts b/apps/app-frontend/src/components/ui/context-menu/utils.ts new file mode 100644 index 0000000000..e7bd7c79d2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/utils.ts @@ -0,0 +1,59 @@ +import type { + ContextMenuAction, + ContextMenuDivider, + ContextMenuOption, + ContextMenuParentAction, + Point, +} from './types' + +export function isAction(option: ContextMenuOption): option is ContextMenuAction { + return 'name' in option +} + +export function isDivider(option: ContextMenuOption): option is ContextMenuDivider { + return 'type' in option && option.type === 'divider' +} + +export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction { + return isAction(option) && Boolean(option.children?.length) +} + +export function isInstanceLink(value: unknown) { + if (!value || typeof value !== 'object') return false + + if ('instance' in value) { + const instance = value.instance + return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link) + } + + return 'link' in value && Boolean(value.link) +} + +export function getFocusableButtons(buttonRefs: Map) { + return [...buttonRefs.entries()] + .sort(([left], [right]) => left - right) + .map(([, button]) => button) + .filter((button) => button.offsetParent !== null) +} + +export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) { + if (!buttons.length) return + + const currentIndex = buttons.indexOf(document.activeElement as HTMLElement) + const nextIndex = + currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction + buttons[(nextIndex + buttons.length) % buttons.length]?.focus() +} + +export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) { + const area = triangleArea(a, b, c) + const area1 = triangleArea(point, b, c) + const area2 = triangleArea(a, point, c) + const area3 = triangleArea(a, b, point) + + return Math.abs(area - (area1 + area2 + area3)) < 0.5 +} + +function triangleArea(a: Point, b: Point, c: Point) { + return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2) +} diff --git a/apps/app-frontend/src/components/ui/friends/FriendsList.vue b/apps/app-frontend/src/components/ui/friends/FriendsList.vue index 705b3c16a1..7294781e87 100644 --- a/apps/app-frontend/src/components/ui/friends/FriendsList.vue +++ b/apps/app-frontend/src/components/ui/friends/FriendsList.vue @@ -15,17 +15,17 @@ import { computed, ref } from 'vue' import FriendsSection from '@/components/ui/friends/FriendsSection.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' +import { useAppSettings } from '@/composables/use-app-settings.ts' import { useFriends } from '@/composables/use-friends' import type { FriendWithUserData } from '@/helpers/friends.ts' import type { ModrinthCredentials } from '@/helpers/mr_auth' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' -import { useTheming } from '@/store/state' const { formatMessage } = useVIntl() const { handleError } = injectNotificationManager() const formatRelativeTime = useRelativeTime() -const themeStore = useTheming() +const appSettings = useAppSettings() const props = defineProps<{ credentials: ModrinthCredentials | null @@ -39,11 +39,11 @@ type FriendsSectionCollapsedFlag = | 'friends_pending_collapsed' function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) { - return themeStore.getFeatureFlag(flag) + return appSettings.getFeatureFlag(flag) } function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) { - themeStore.featureFlags[flag] = collapsed + appSettings.featureFlags[flag] = collapsed getSettings() .then((settings) => { settings.feature_flags[flag] = collapsed diff --git a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue index e53e7cc0f6..e9678ec7b7 100644 --- a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue +++ b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue @@ -11,7 +11,7 @@ import { import { useTemplateRef } from 'vue' import { useRouter } from 'vue-router' -import ContextMenu from '@/components/ui/ContextMenu.vue' +import ContextMenu from '@/components/ui/context-menu/index.vue' import type { FriendWithUserData } from '@/helpers/friends.ts' const { formatMessage } = useVIntl() diff --git a/apps/app-frontend/src/components/ui/install_flow/UnknownPackWarningModal.vue b/apps/app-frontend/src/components/ui/install_flow/UnknownPackWarningModal.vue index f1b929e050..b1063c805d 100644 --- a/apps/app-frontend/src/components/ui/install_flow/UnknownPackWarningModal.vue +++ b/apps/app-frontend/src/components/ui/install_flow/UnknownPackWarningModal.vue @@ -13,11 +13,10 @@ import { UnknownFileWarningModal } from '@modrinth/ui' import { ref, useTemplateRef } from 'vue' +import { type FeatureFlag, useAppSettings } from '@/composables/use-app-settings.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings' -import { useTheming } from '@/store/state' -import type { FeatureFlag } from '@/store/theme.ts' -const themeStore = useTheming() +const appSettings = useAppSettings() const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag const modal = useTemplateRef('modal') @@ -34,7 +33,7 @@ function show( fileName.value = selectedFileName externalFilesInModpack.value = selectedExternalFiles - if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) { + if (appSettings.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) { void createInstance() return } @@ -50,7 +49,7 @@ function reset() { async function proceed(dontShowAgain: boolean) { if (dontShowAgain) { - themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true + appSettings.featureFlags[skipUnknownPackWarningFeatureFlag] = true const settings = await getSettings() settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true await setSettings(settings) diff --git a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts index 18f6d06435..7fa45b51a5 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts +++ b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts @@ -1,4 +1,4 @@ -import { defineMessages } from '@modrinth/ui' +import { defineMessages, type MessageDescriptor } from '@modrinth/ui' import backpack from '@/assets/instance-icons/backpack.png' import beacon from '@/assets/instance-icons/beacon.png' @@ -18,6 +18,8 @@ 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 fabric from '@/assets/instance-icons/fabric.png' +import forge from '@/assets/instance-icons/forge.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' @@ -25,11 +27,13 @@ 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 neoForge from '@/assets/instance-icons/neoforge.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 quilt from '@/assets/instance-icons/quilt.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' @@ -197,8 +201,20 @@ const names = defineMessages({ defaultMessage: 'Modrinth Wrench', }, zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' }, + fabric: { id: 'instance.icon-editor.symbol.fabric', defaultMessage: 'Fabric' }, + forge: { id: 'instance.icon-editor.symbol.forge', defaultMessage: 'Forge' }, + neoForge: { id: 'instance.icon-editor.symbol.neoforge', defaultMessage: 'NeoForge' }, + quilt: { id: 'instance.icon-editor.symbol.quilt', defaultMessage: 'Quilt' }, }) +export interface SymbolOption { + id: string + name: MessageDescriptor + asset: string + category: 'loader' | 'modded' | 'vanilla' + excludeFromRandomization?: boolean +} + export const backgroundOptions = [ { id: 'rose', @@ -339,6 +355,39 @@ export const backgroundOptions = [ ] as const export const symbolOptions = [ + ///////////////////////// + // loaders + ///////////////////////// + + { + id: 'fabric', + name: names.fabric, + asset: fabric, + category: 'loader', + excludeFromRandomization: true, + }, + { + id: 'forge', + name: names.forge, + asset: forge, + category: 'loader', + excludeFromRandomization: true, + }, + { + id: 'neoforge', + name: names.neoForge, + asset: neoForge, + category: 'loader', + excludeFromRandomization: true, + }, + { + id: 'quilt', + name: names.quilt, + asset: quilt, + category: 'loader', + excludeFromRandomization: true, + }, + // Cobblemon: Poké Ball { id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' }, @@ -438,7 +487,7 @@ export const symbolOptions = [ { 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 +] as const satisfies readonly SymbolOption[] export type BackgroundId = (typeof backgroundOptions)[number]['id'] export type SymbolId = (typeof symbolOptions)[number]['id'] diff --git a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue index 12c53f983d..7ecf4ba41b 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue @@ -1,6 +1,7 @@ -