diff --git a/CLAUDE.md b/CLAUDE.md index 6161ef281f..12cb942a03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,10 +67,22 @@ Each project may have its own `CLAUDE.md` with detailed instructions: - [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API - [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website +## Skills (`.claude/skills/`) + +Project-specific skill files with detailed patterns. Use them when the task matches: + +- **`api-module`** — Adding a new API endpoint module to `packages/api-client` (types, module class, registry registration) +- **`cross-platform-pages`** — Building a page that needs to work in both the website (`apps/frontend`) and the desktop app (`apps/app-frontend`) +- **`dependency-injection`** — Creating or wiring up a `provide`/`inject` context for platform abstraction or deep component state sharing +- **`figma-mcp`** — Translating a Figma design into Vue components using the Figma MCP tools +- **`i18n-convert`** — Converting hardcoded English strings in Vue SFCs into the `@modrinth/ui` i18n system (`defineMessages`, `formatMessage`, `IntlFormatted`) +- **`multistage-modals`** — Building a wizard-like modal with multiple stages, progress tracking, and per-stage buttons using `MultiStageModal` +- **`tanstack-query`** — Fetching, caching, or mutating server data with `@tanstack/vue-query` (queries, mutations, invalidation, optimistic updates) + ## Code Guidelines ### Comments -- DO NOT use "heading" comments like: // === Helper methods === . +- DO NOT use "heading" comments like: `=== Helper methods ===`. - Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting! ## Bash Guidelines diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index c7561a588b..2352839df3 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -28,17 +28,18 @@ "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", "@types/three": "^0.172.0", - "intl-messageformat": "^10.7.7", - "vue-i18n": "^10.0.0", "@vueuse/core": "^11.1.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", + "fuse.js": "^6.6.2", + "intl-messageformat": "^10.7.7", "ofetch": "^1.3.4", "pinia": "^3.0.0", "posthog-js": "^1.158.2", "three": "^0.172.0", "vite-svg-loader": "^5.1.0", "vue": "^3.5.13", + "vue-i18n": "^10.0.0", "vue-multiselect": "3.0.0", "vue-router": "^4.6.0", "vue-virtual-scroller": "v2.0.0-beta.8" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 601d7172a6..ffd34fb374 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -31,12 +31,15 @@ import { Button, ButtonStyled, commonMessages, + ContentInstallModal, + CreationFlowModal, defineMessages, I18nDebugPanel, NewsArticleCard, NotificationPanel, OverflowMenu, ProgressSpinner, + provideModalBehavior, provideModrinthClient, provideNotificationManager, providePageContext, @@ -63,8 +66,6 @@ import ErrorModal from '@/components/ui/ErrorModal.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue' import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue' import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue' -import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue' -import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' @@ -84,6 +85,7 @@ import { get_user } from '@/helpers/cache.js' import { command_listener, warning_listener } from '@/helpers/events.js' import { useFetch } from '@/helpers/fetch.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' +import { create_profile_and_install_from_file } from '@/helpers/pack' import { list } from '@/helpers/profile.js' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' @@ -96,15 +98,15 @@ import { isNetworkMetered, } from '@/helpers/utils.js' import i18n from '@/i18n.config' +import { createContentInstall, provideContentInstall } from '@/providers/content-install' import { provideAppUpdateDownloadProgress, subscribeToDownloadProgress, } from '@/providers/download-progress.ts' +import { setupProviders } from '@/providers/setup' import { useError } from '@/store/error.js' -import { useInstall } from '@/store/install.js' import { useLoading, useTheming } from '@/store/state' -import { create_profile_and_install_from_file } from './helpers/pack' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { get_available_capes, get_available_skins } from './helpers/skins' import { AppNotificationManager } from './providers/app-notifications' @@ -129,6 +131,15 @@ providePageContext({ hierarchicalSidebarAvailable: ref(true), showAds: ref(false), }) +provideModalBehavior({ + noblur: computed(() => !themeStore.advancedRendering), + onShow: () => hide_ads_window(), + onHide: () => show_ads_window(), +}) + +const { installationModal, handleCreate, handleBrowseModpacks } = + setupProviders(notificationManager) + const news = ref([]) const availableSurvey = ref(false) @@ -391,7 +402,25 @@ const error = useError() const errorModal = ref() const minecraftAuthErrorModal = ref() -const install = useInstall() +const contentInstall = createContentInstall({ router, handleError }) +provideContentInstall(contentInstall) +const { + instances: contentInstallInstances, + compatibleLoaders: contentInstallLoaders, + gameVersions: contentInstallGameVersions, + loading: contentInstallLoading, + defaultTab: contentInstallDefaultTab, + preferredLoader: contentInstallPreferredLoader, + preferredGameVersion: contentInstallPreferredGameVersion, + releaseGameVersions: contentInstallReleaseGameVersions, + handleInstallToInstance, + handleCreateAndInstall, + handleCancel: handleContentInstallCancel, + setContentInstallModal, + setInstallConfirmModal: setContentInstallConfirmModal, + setIncompatibilityWarningModal: setContentIncompatibilityWarningModal, +} = contentInstall + const modInstallModal = ref() const installConfirmModal = ref() const incompatibilityWarningModal = ref() @@ -470,9 +499,9 @@ onMounted(() => { error.setErrorModal(errorModal.value) error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value) - install.setIncompatibilityWarningModal(incompatibilityWarningModal) - install.setInstallConfirmModal(installConfirmModal) - install.setModInstallModal(modInstallModal) + setContentIncompatibilityWarningModal(incompatibilityWarningModal.value) + setContentInstallConfirmModal(installConfirmModal.value) + setContentInstallModal(modInstallModal.value) }) const accounts = ref(null) @@ -801,9 +830,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload) - - - +
@@ -849,7 +882,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @@ -937,9 +970,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
- -
+
+ +
-
+
- + diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue index a8e8970aed..56c65fa0b4 100644 --- a/apps/app-frontend/src/components/RowDisplay.vue +++ b/apps/app-frontend/src/components/RowDisplay.vue @@ -24,10 +24,11 @@ import { trackEvent } from '@/helpers/analytics' import { get_by_profile_path } from '@/helpers/process.js' import { duplicate, kill, remove, run } from '@/helpers/profile.js' import { showProfileInFolder } from '@/helpers/utils.js' +import { injectContentInstall } from '@/providers/content-install' import { handleSevereError } from '@/store/error.js' -import { install as installVersion } from '@/store/install.js' const { handleError } = injectNotificationManager() +const { install: installVersion } = injectContentInstall() const router = useRouter() diff --git a/apps/app-frontend/src/components/ui/Breadcrumbs.vue b/apps/app-frontend/src/components/ui/Breadcrumbs.vue index ee12e17ea7..144ed5ebc0 100644 --- a/apps/app-frontend/src/components/ui/Breadcrumbs.vue +++ b/apps/app-frontend/src/components/ui/Breadcrumbs.vue @@ -1,64 +1,147 @@ - + + diff --git a/apps/app-frontend/src/components/ui/ExportModal.vue b/apps/app-frontend/src/components/ui/ExportModal.vue index 4966b1c7bd..fa605f3206 100644 --- a/apps/app-frontend/src/components/ui/ExportModal.vue +++ b/apps/app-frontend/src/components/ui/ExportModal.vue @@ -1,6 +1,14 @@ @@ -374,7 +366,6 @@ import { ChevronLeftIcon, CompassIcon, EditIcon, - EmptyIllustration, GlobeIcon, HeartMinusIcon, LinkIcon, @@ -395,6 +386,7 @@ import { ConfirmModal, defineMessage, defineMessages, + EmptyState, FileInput, HorizontalRule, injectModrinthClient, diff --git a/apps/frontend/src/pages/dashboard/revenue/transfers.vue b/apps/frontend/src/pages/dashboard/revenue/transfers.vue index 3b08a8195a..93dd0b4c50 100644 --- a/apps/frontend/src/pages/dashboard/revenue/transfers.vue +++ b/apps/frontend/src/pages/dashboard/revenue/transfers.vue @@ -72,14 +72,11 @@
-
- {{ - formatMessage(messages.noTransactions) - }} - {{ - formatMessage(messages.noTransactionsDesc) - }} -
+
+ + diff --git a/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue deleted file mode 100644 index 5e15864881..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue +++ /dev/null @@ -1,704 +0,0 @@ - - - - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/index.vue index 4d39cbff51..be987c76cf 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/index.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/index.vue @@ -182,13 +182,12 @@ diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue index 397e67402e..f9fd5f60eb 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue @@ -58,7 +58,7 @@
@@ -72,10 +72,10 @@

We couldn't load your server's network settings. Here's what we know: {{ - JSON.stringify(server.moduleErrors.network.error) + allocationsError?.message ?? 'Unknown error' }}

- +
@@ -249,7 +249,7 @@
() +const { server, serverId } = injectModrinthServerContext() +const client = injectModrinthClient() +const queryClient = useQueryClient() const isUpdating = ref(false) -const data = computed(() => props.server.general) +const data = server const serverIP = ref(data?.value?.net?.ip ?? '') const serverSubdomain = ref(data?.value?.net?.domain ?? '') @@ -296,8 +298,15 @@ const serverPrimaryPort = ref(data?.value?.net?.port ?? 0) const userDomain = ref('') const exampleDomain = 'play.example.com' -const network = computed(() => props.server.network) -const allocations = computed(() => network.value?.allocations) +const { + data: allocationsData, + error: allocationsError, + refetch: refetchAllocations, +} = useQuery({ + queryKey: ['servers', 'allocations', serverId] as const, + queryFn: () => client.archon.servers_v0.getAllocations(serverId), +}) +const allocations = allocationsData const newAllocationModal = ref() const editAllocationModal = ref() @@ -316,8 +325,8 @@ const addNewAllocation = async () => { if (!newAllocationName.value) return try { - await props.server.network?.reserveAllocation(newAllocationName.value) - await props.server.refresh(['network']) + await client.archon.servers_v0.reserveAllocation(serverId, newAllocationName.value) + await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] }) newAllocationModal.value?.hide() newAllocationName.value = '' @@ -360,8 +369,8 @@ const showConfirmDeleteModal = (port: number) => { const confirmDeleteAllocation = async () => { if (allocationToDelete.value === null) return - await props.server.network?.deleteAllocation(allocationToDelete.value) - await props.server.refresh(['network']) + await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value) + await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] }) addNotification({ type: 'success', @@ -376,8 +385,12 @@ const editAllocation = async () => { if (!newAllocationName.value) return try { - await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value) - await props.server.refresh(['network']) + await client.archon.servers_v0.updateAllocation( + serverId, + newAllocationPort.value, + newAllocationName.value, + ) + await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] }) editAllocationModal.value?.hide() newAllocationName.value = '' @@ -397,7 +410,8 @@ const saveNetwork = async () => { try { isUpdating.value = true - const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value) + const result = await client.archon.servers_v0.checkSubdomainAvailability(serverSubdomain.value) + const available = result.available if (!available) { addNotification({ type: 'error', @@ -407,13 +421,18 @@ const saveNetwork = async () => { return } if (serverSubdomain.value !== data?.value?.net?.domain) { - await props.server.network?.changeSubdomain(serverSubdomain.value) + await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value) } if (serverPrimaryPort.value !== data?.value?.net?.port) { - await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value) + await client.archon.servers_v0.updateAllocation( + serverId, + serverPrimaryPort.value, + newAllocationName.value, + ) } await new Promise((resolve) => setTimeout(resolve, 500)) - await props.server.refresh() + await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }) + await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] }) addNotification({ type: 'success', title: 'Server settings updated', diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue index 4102a6b5c7..d3c84bf5b0 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/options/preferences.vue @@ -32,7 +32,7 @@
() - const preferences = { ramAsNumber: { displayName: 'RAM as bytes', diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue index 4d2d524038..3616062ba3 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue @@ -1,9 +1,6 @@ diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue new file mode 100644 index 0000000000..ae0389d32d --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue @@ -0,0 +1,174 @@ + + + diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue new file mode 100644 index 0000000000..c2972428bb --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/ImportInstanceStage.vue @@ -0,0 +1,283 @@ + + + diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue new file mode 100644 index 0000000000..cfa6f27240 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue @@ -0,0 +1,169 @@ + + + diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/SetupTypeStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/SetupTypeStage.vue new file mode 100644 index 0000000000..8ad6723c14 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/SetupTypeStage.vue @@ -0,0 +1,74 @@ + + + diff --git a/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts new file mode 100644 index 0000000000..c9ea388d91 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts @@ -0,0 +1,332 @@ +import { computed, type ComputedRef, type Ref, ref, type ShallowRef, watch } from 'vue' +import type { ComponentExposed } from 'vue-component-type-helpers' + +import { createContext } from '../../../providers' +import type { ImportableLauncher } from '../../../providers/instance-import' +import type { MultiStageModal, StageConfigInput } from '../../base' +import type { ComboboxOption } from '../../base/Combobox.vue' +import { stageConfigs } from './stages' + +export type FlowType = 'world' | 'server-onboarding' | 'instance' +export type SetupType = 'modpack' | 'custom' | 'vanilla' +export type Gamemode = 'survival' | 'creative' | 'hardcore' +export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard' +export type LoaderVersionType = 'stable' | 'latest' | 'other' +export type GeneratorSettingsMode = 'default' | 'flat' | 'custom' + +export interface ModpackSelection { + projectId: string + versionId: string + name: string + iconUrl?: string +} + +export interface ModpackSearchHit { + title: string + iconUrl?: string + latestVersion?: string +} + +export const flowTypeHeadings: Record = { + world: 'Create world', + 'server-onboarding': 'Set up server', + instance: 'Create instance', +} + +export interface CreationFlowContextValue { + // Flow + flowType: FlowType + + // Configuration + availableLoaders: string[] + showSnapshotToggle: boolean + disableClose: boolean + isInitialSetup: boolean + + // Initial values + initialLoader: string | null + initialGameVersion: string | null + + // State + setupType: Ref + isImportMode: Ref + worldName: Ref + gamemode: Ref + difficulty: Ref + worldSeed: Ref + worldTypeOption: Ref + generateStructures: Ref + generatorSettingsMode: Ref + generatorSettingsCustom: Ref + + // Instance-specific state + instanceName: Ref + instanceIcon: Ref + instanceIconUrl: Ref + instanceIconPath: Ref + + // Loader/version state (custom setup) + selectedLoader: Ref + selectedGameVersion: Ref + loaderVersionType: Ref + selectedLoaderVersion: Ref + hideLoaderChips: ComputedRef + hideLoaderVersion: ComputedRef + showSnapshots: Ref + + // Modpack state + modpackSelection: Ref + modpackFile: Ref + modpackFilePath: Ref + + // Modpack search state (persisted across stage navigation) + modpackSearchProjectId: Ref + modpackSearchVersionId: Ref + modpackSearchOptions: Ref[]> + modpackVersionOptions: Ref[]> + modpackSearchHits: Ref> + + // Import state (instance flow only) + importLaunchers: Ref + importSelectedInstances: Ref>> + importSearchQuery: Ref + + // Confirm stage + hardReset: Ref + + // Loading state (set when finish() is called, cleared on reset) + loading: Ref + + // Modal + modal: ShallowRef | null> + stageConfigs: StageConfigInput[] + + // Callbacks + onBack: (() => void) | null + + // Methods + reset: () => void + setSetupType: (type: SetupType) => void + setImportMode: () => void + browseModpacks: () => void + finish: () => void +} + +export const [injectCreationFlowContext, provideCreationFlowContext] = + createContext('CreationFlowModal') + +// TODO: replace with actual world count from the world list once available +let worldCounter = 0 + +export interface CreationFlowOptions { + availableLoaders?: string[] + showSnapshotToggle?: boolean + disableClose?: boolean + isInitialSetup?: boolean + initialLoader?: string + initialGameVersion?: string + onBack?: () => void +} + +export function createCreationFlowContext( + modal: ShallowRef | null>, + flowType: FlowType, + emit: { + browseModpacks: () => void + create: (config: CreationFlowContextValue) => void + }, + options: CreationFlowOptions = {}, +): CreationFlowContextValue { + const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt'] + const showSnapshotToggle = options.showSnapshotToggle ?? false + const disableClose = options.disableClose ?? false + const isInitialSetup = options.isInitialSetup ?? false + const initialLoader = options.initialLoader ?? null + const initialGameVersion = options.initialGameVersion ?? null + const onBack = options.onBack ?? null + + const setupType = ref(null) + const isImportMode = ref(false) + const worldName = ref('') + const gamemode = ref('survival') + const difficulty = ref('normal') + const worldSeed = ref('') + const worldTypeOption = ref('minecraft:normal') + const generateStructures = ref(true) + const generatorSettingsMode = ref('default') + const generatorSettingsCustom = ref('') + + // Instance-specific state + const instanceName = ref('') + const instanceIcon = ref(null) + const instanceIconUrl = ref(null) + const instanceIconPath = ref(null) + + // Revoke old object URL when icon is cleared to avoid memory leaks + watch(instanceIconUrl, (_newUrl, oldUrl) => { + if (oldUrl && oldUrl.startsWith('blob:')) { + URL.revokeObjectURL(oldUrl) + } + }) + + const selectedLoader = ref(null) + const selectedGameVersion = ref(null) + const loaderVersionType = ref('stable') + const selectedLoaderVersion = ref(null) + const showSnapshots = ref(false) + + const modpackSelection = ref(null) + const modpackFile = ref(null) + const modpackFilePath = ref(null) + + // Modpack search state (persisted across stage navigation) + const modpackSearchProjectId = ref() + const modpackSearchVersionId = ref() + const modpackSearchOptions = ref[]>([]) + const modpackVersionOptions = ref[]>([]) + const modpackSearchHits = ref>({}) + + // Import state (instance flow only) + const importLaunchers = ref([]) + const importSelectedInstances = ref>>({}) + const importSearchQuery = ref('') + + const hardReset = ref(isInitialSetup) + const loading = ref(false) + + // hideLoaderChips: hides the entire loader chips section (only for vanilla world type in world/server flows) + const hideLoaderChips = computed(() => setupType.value === 'vanilla') + + // hideLoaderVersion: hides the loader version section (vanilla world type OR vanilla selected as loader chip) + const hideLoaderVersion = computed( + () => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla', + ) + + function reset() { + setupType.value = null + isImportMode.value = false + worldCounter++ + worldName.value = flowType === 'world' ? `World ${worldCounter}` : '' + gamemode.value = 'survival' + difficulty.value = 'normal' + worldSeed.value = '' + worldTypeOption.value = 'minecraft:normal' + generateStructures.value = true + generatorSettingsMode.value = 'default' + generatorSettingsCustom.value = '' + + // Instance-specific + instanceName.value = '' + instanceIconUrl.value = null + instanceIcon.value = null + instanceIconPath.value = null + + selectedLoader.value = null + selectedGameVersion.value = null + loaderVersionType.value = 'stable' + selectedLoaderVersion.value = null + showSnapshots.value = false + modpackSelection.value = null + modpackFile.value = null + modpackFilePath.value = null + modpackSearchProjectId.value = undefined + modpackSearchVersionId.value = undefined + modpackSearchOptions.value = [] + modpackVersionOptions.value = [] + modpackSearchHits.value = {} + + // Import state + importLaunchers.value = [] + importSelectedInstances.value = {} + importSearchQuery.value = '' + + hardReset.value = isInitialSetup + loading.value = false + } + + function setSetupType(type: SetupType) { + isImportMode.value = false + setupType.value = type + if (type === 'modpack') { + modal.value?.setStage('modpack') + } else { + // both custom and vanilla go to custom-setup + // vanilla just hides loader chips via hideLoaderChips computed + modal.value?.setStage('custom-setup') + } + } + + function setImportMode() { + isImportMode.value = true + setupType.value = null + modal.value?.setStage('import-instance') + } + + function browseModpacks() { + modal.value?.hide() + emit.browseModpacks() + } + + function finish() { + loading.value = true + emit.create(contextValue) + } + + const resolvedStageConfigs = disableClose + ? stageConfigs.map((stage) => ({ ...stage, disableClose: true })) + : stageConfigs + + const contextValue: CreationFlowContextValue = { + flowType, + availableLoaders, + showSnapshotToggle, + disableClose, + isInitialSetup, + initialLoader, + initialGameVersion, + setupType, + isImportMode, + worldName, + gamemode, + difficulty, + worldSeed, + worldTypeOption, + generateStructures, + generatorSettingsMode, + generatorSettingsCustom, + instanceName, + instanceIcon, + instanceIconUrl, + instanceIconPath, + selectedLoader, + selectedGameVersion, + loaderVersionType, + selectedLoaderVersion, + hideLoaderChips, + hideLoaderVersion, + showSnapshots, + modpackSelection, + modpackFile, + modpackFilePath, + modpackSearchProjectId, + modpackSearchVersionId, + modpackSearchOptions, + modpackVersionOptions, + modpackSearchHits, + importLaunchers, + importSelectedInstances, + importSearchQuery, + hardReset, + loading, + modal, + stageConfigs: resolvedStageConfigs, + onBack, + reset, + setSetupType, + setImportMode, + browseModpacks, + finish, + } + + return contextValue +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/index.vue b/packages/ui/src/components/flows/creation-flow-modal/index.vue new file mode 100644 index 0000000000..c1f095143b --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/index.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/ui/src/components/flows/creation-flow-modal/shared.ts b/packages/ui/src/components/flows/creation-flow-modal/shared.ts new file mode 100644 index 0000000000..73f69bf0c8 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/shared.ts @@ -0,0 +1,14 @@ +export const loaderDisplayNames: Record = { + fabric: 'Fabric', + neoforge: 'NeoForge', + forge: 'Forge', + quilt: 'Quilt', + paper: 'Paper', + purpur: 'Purpur', + vanilla: 'Vanilla', +} + +export const formatLoaderLabel = (item: string) => + loaderDisplayNames[item] ?? item.charAt(0).toUpperCase() + item.slice(1) + +export const capitalize = (item: string) => item.charAt(0).toUpperCase() + item.slice(1) diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts new file mode 100644 index 0000000000..6650a886af --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/custom-setup-stage.ts @@ -0,0 +1,67 @@ +import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets' +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import CustomSetupStage from '../components/CustomSetupStage.vue' +import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' + +function isForwardBlocked(ctx: CreationFlowContextValue): boolean { + if (!ctx.selectedGameVersion.value) return true + if (!ctx.hideLoaderChips.value && !ctx.selectedLoader.value) return true + if ( + !ctx.hideLoaderVersion.value && + ctx.loaderVersionType.value === 'other' && + !ctx.selectedLoaderVersion.value + ) + return true + return false +} + +export const stageConfig: StageConfigInput = { + id: 'custom-setup', + title: (ctx) => flowTypeHeadings[ctx.flowType], + stageContent: markRaw(CustomSetupStage), + skip: (ctx) => + ctx.setupType.value === 'modpack' || + ctx.setupType.value === 'vanilla' || + ctx.isImportMode.value, + cannotNavigateForward: isForwardBlocked, + leftButtonConfig: (ctx) => ({ + label: 'Back', + icon: LeftArrowIcon, + onClick: () => ctx.modal.value?.setStage('setup-type'), + }), + rightButtonConfig: (ctx) => { + const isInstance = ctx.flowType === 'instance' + const goesToNextStage = ctx.flowType === 'world' || ctx.flowType === 'server-onboarding' + const disabled = isForwardBlocked(ctx) + + if (isInstance) { + return { + label: 'Create instance', + icon: PlusIcon, + iconPosition: 'before' as const, + color: 'brand' as const, + disabled, + loading: ctx.loading.value, + onClick: () => ctx.finish(), + } + } + + return { + label: goesToNextStage ? 'Continue' : 'Finish', + icon: goesToNextStage ? RightArrowIcon : null, + iconPosition: 'after' as const, + color: goesToNextStage ? undefined : ('brand' as const), + disabled, + onClick: () => { + if (goesToNextStage) { + ctx.modal.value?.nextStage() + } else { + ctx.finish() + } + }, + } + }, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts new file mode 100644 index 0000000000..8524217b81 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts @@ -0,0 +1,52 @@ +import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets' +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import FinalConfigStage from '../components/FinalConfigStage.vue' +import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' + +function isForwardBlocked(ctx: CreationFlowContextValue): boolean { + if (ctx.flowType === 'world' && !ctx.worldName.value.trim()) return true + if (ctx.setupType.value === 'vanilla' && !ctx.selectedGameVersion.value) return true + return false +} + +export const stageConfig: StageConfigInput = { + id: 'final-config', + title: (ctx) => flowTypeHeadings[ctx.flowType], + stageContent: markRaw(FinalConfigStage), + skip: (ctx) => ctx.flowType === 'instance' || ctx.isImportMode.value, + cannotNavigateForward: isForwardBlocked, + leftButtonConfig: (ctx) => ({ + label: 'Back', + icon: LeftArrowIcon, + onClick: () => { + if (ctx.onBack) { + ctx.onBack() + } else { + ctx.modal.value?.prevStage() + } + }, + }), + rightButtonConfig: (ctx) => { + const isWorld = ctx.flowType === 'world' + const isOnboarding = ctx.flowType === 'server-onboarding' + const isFinish = isWorld || isOnboarding + return { + label: isWorld ? 'Create world' : isOnboarding ? 'Setup server' : 'Continue', + icon: isFinish ? PlusIcon : RightArrowIcon, + iconPosition: isFinish ? ('before' as const) : ('after' as const), + color: isFinish ? ('brand' as const) : undefined, + disabled: isForwardBlocked(ctx), + loading: isFinish && ctx.loading.value, + onClick: () => { + if (isFinish) { + ctx.finish() + } else { + ctx.modal.value?.nextStage() + } + }, + } + }, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts new file mode 100644 index 0000000000..7d6b94399c --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/import-instance-stage.ts @@ -0,0 +1,41 @@ +import { DownloadIcon, LeftArrowIcon } from '@modrinth/assets' +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import ImportInstanceStage from '../components/ImportInstanceStage.vue' +import type { CreationFlowContextValue } from '../creation-flow-context' + +function getSelectedCount(ctx: CreationFlowContextValue): number { + let count = 0 + for (const set of Object.values(ctx.importSelectedInstances.value)) { + count += set.size + } + return count +} + +export const stageConfig: StageConfigInput = { + id: 'import-instance', + title: 'Import instance', + stageContent: markRaw(ImportInstanceStage), + skip: (ctx) => !ctx.isImportMode.value, + leftButtonConfig: (ctx) => ({ + label: 'Back', + icon: LeftArrowIcon, + onClick: () => { + ctx.isImportMode.value = false + ctx.modal.value?.setStage('setup-type') + }, + }), + rightButtonConfig: (ctx) => { + const count = getSelectedCount(ctx) + return { + label: count > 0 ? `Import ${count} instance${count !== 1 ? 's' : ''}` : 'Import', + icon: DownloadIcon, + iconPosition: 'before' as const, + color: 'brand' as const, + disabled: count === 0, + onClick: () => ctx.finish(), + } + }, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts new file mode 100644 index 0000000000..0cd429c47c --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts @@ -0,0 +1,15 @@ +import type { StageConfigInput } from '../../../base' +import type { CreationFlowContextValue } from '../creation-flow-context' +import { stageConfig as customSetupStageConfig } from './custom-setup-stage' +import { stageConfig as finalConfigStageConfig } from './final-config-stage' +import { stageConfig as importInstanceStageConfig } from './import-instance-stage' +import { stageConfig as modpackStageConfig } from './modpack-stage' +import { stageConfig as setupTypeStageConfig } from './setup-type-stage' + +export const stageConfigs: StageConfigInput[] = [ + setupTypeStageConfig, + modpackStageConfig, + importInstanceStageConfig, + customSetupStageConfig, + finalConfigStageConfig, +] diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts new file mode 100644 index 0000000000..1e83daa86d --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/modpack-stage.ts @@ -0,0 +1,20 @@ +import { LeftArrowIcon } from '@modrinth/assets' +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import ModpackStage from '../components/ModpackStage.vue' +import type { CreationFlowContextValue } from '../creation-flow-context' + +export const stageConfig: StageConfigInput = { + id: 'modpack', + title: 'Choose modpack', + stageContent: markRaw(ModpackStage), + skip: (ctx) => ctx.setupType.value !== 'modpack' || ctx.isImportMode.value, + leftButtonConfig: (ctx) => ({ + label: 'Back', + icon: LeftArrowIcon, + onClick: () => ctx.modal.value?.setStage('setup-type'), + }), + rightButtonConfig: null, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts new file mode 100644 index 0000000000..9d3db57914 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/setup-type-stage.ts @@ -0,0 +1,14 @@ +import { markRaw } from 'vue' + +import type { StageConfigInput } from '../../../base' +import SetupTypeStage from '../components/SetupTypeStage.vue' +import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context' + +export const stageConfig: StageConfigInput = { + id: 'setup-type', + title: (ctx) => flowTypeHeadings[ctx.flowType], + stageContent: markRaw(SetupTypeStage), + leftButtonConfig: null, + rightButtonConfig: null, + maxWidth: '520px', +} diff --git a/packages/ui/src/components/instances/ContentCardItem.vue b/packages/ui/src/components/instances/ContentCardItem.vue index 078166a704..1da41af28d 100644 --- a/packages/ui/src/components/instances/ContentCardItem.vue +++ b/packages/ui/src/components/instances/ContentCardItem.vue @@ -1,6 +1,6 @@