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..484f13125c --- /dev/null +++ b/.github/workflows/frontend-docker.yml @@ -0,0 +1,104 @@ +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 + workflow_dispatch: + +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 + + # nuxi is called directly because `pnpm build` hardcodes NODE_OPTIONS, which would + # drop the semi-space bump that cuts roughly 5% off the build. + - name: Build frontend + working-directory: ./apps/frontend + run: pnpm exec nuxi build + env: + NITRO_PRESET: node-server + NODE_OPTIONS: '--max-old-space-size=8192 --max-semi-space-size=64' + 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/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..7456fa2dec 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,79 @@ 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, + 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`, { @@ -1642,7 +1720,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
+
{{ formatMessage(developerModeEnabled) }}
{{ formatMessage(messages.colorThemeDescription) }}
+async function saveAppearanceSettings(): Promise- {{ formatMessage(messages.advancedRenderingDescription) }} -
-{{ formatMessage(messages.nativeDecorationsDescription) }}
-+ {{ formatMessage(messages.syncAcrossDevicesDescription) }} +
+{{ formatMessage(messages.toggleSidebarDescription) }}
{{ formatMessage(messages.showPlayTimeDescription) }}
{{ formatMessage(messages.hideNametagDescription) }}
-
+ {{ description }} +
+