diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 67f1572597..1c4996fac5 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -7,6 +7,7 @@ export type IconComponent = FunctionalComponent import _AffiliateIcon from './icons/affiliate.svg?component' import _AlignLeftIcon from './icons/align-left.svg?component' +import _AnvilIcon from './icons/anvil.svg?component' import _ArchiveIcon from './icons/archive.svg?component' import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component' import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component' @@ -477,6 +478,7 @@ import _ZoomOutIcon from './icons/zoom-out.svg?component' export const AffiliateIcon = _AffiliateIcon export const AlignLeftIcon = _AlignLeftIcon +export const AnvilIcon = _AnvilIcon export const ArchiveIcon = _ArchiveIcon export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon diff --git a/packages/assets/icons/anvil.svg b/packages/assets/icons/anvil.svg new file mode 100644 index 0000000000..1cb352b8ff --- /dev/null +++ b/packages/assets/icons/anvil.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/packages/ui/src/components/base/DropzoneFileInput.vue b/packages/ui/src/components/base/DropzoneFileInput.vue index cac41e5e63..4363dcfcea 100644 --- a/packages/ui/src/components/base/DropzoneFileInput.vue +++ b/packages/ui/src/components/base/DropzoneFileInput.vue @@ -25,7 +25,7 @@
-
+
{{ primaryPrompt }}
@@ -39,7 +39,7 @@ :multiple="multiple" :accept="accept" :disabled="disabled" - class="hidden" + class="sr-only" @change="handleChange" /> 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 index be58f36ee3..d46a99628c 100644 --- a/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue +++ b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue @@ -116,7 +116,7 @@ + import { + AnvilIcon, BoxesIcon, BoxIcon, BoxImportIcon, @@ -139,6 +149,7 @@ import { injectFilePicker } from '../../../../providers' import BigOptionButton from '../../../base/BigOptionButton.vue' import Combobox from '../../../base/Combobox.vue' import { injectCreationFlowContext } from '../creation-flow-context' +import { curseforgeMessages } from '../curseforge' const debug = useDebugLogger('SetupTypeStage') const ctx = injectCreationFlowContext() diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/curseforge-stage/archive-input.vue b/packages/ui/src/components/flows/creation-flow-modal/components/curseforge-stage/archive-input.vue new file mode 100644 index 0000000000..6dcf5f0ec5 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/curseforge-stage/archive-input.vue @@ -0,0 +1,42 @@ + + + diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/curseforge-stage/index.vue b/packages/ui/src/components/flows/creation-flow-modal/components/curseforge-stage/index.vue new file mode 100644 index 0000000000..0b9a9a6d76 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/components/curseforge-stage/index.vue @@ -0,0 +1,87 @@ + + + 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 index 9bc47b8b8c..7c9e543369 100644 --- 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 @@ -21,7 +21,7 @@ import { createServerOnboardingInvite } from './server-onboarding-invite' import { stageConfigs } from './stages' export type FlowType = 'world' | 'server-onboarding' | 'reset-server' | 'instance' -export type SetupType = 'modpack' | 'custom' | 'vanilla' +export type SetupType = 'modpack' | 'curseforge' | 'custom' | 'vanilla' export type Gamemode = 'survival' | 'creative' | 'hardcore' export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard' export type LoaderVersionType = 'stable' | 'latest' | 'other' @@ -206,6 +206,9 @@ export interface CreationFlowContextValue { modpackSelection: Ref modpackFile: Ref modpackFilePath: Ref + curseforgeUrl: Ref + curseforgeModpackFile: Ref + curseforgeServerPackFile: Ref projectInstall: Ref // Project search state (persisted across stage navigation) @@ -392,6 +395,9 @@ export function createCreationFlowContext( const modpackSelection = ref(null) const modpackFile = ref(null) const modpackFilePath = ref(null) + const curseforgeUrl = ref('') + const curseforgeModpackFile = ref(null) + const curseforgeServerPackFile = ref(null) const projectInstall = ref(null) // Project search state (persisted across stage navigation) @@ -552,6 +558,9 @@ export function createCreationFlowContext( modpackSelection.value = null modpackFile.value = null modpackFilePath.value = null + curseforgeUrl.value = '' + curseforgeModpackFile.value = null + curseforgeServerPackFile.value = null projectInstall.value = null projectSearchProjectId.value = undefined projectSearchOptions.value = [] @@ -573,6 +582,16 @@ export function createCreationFlowContext( isImportMode.value = false projectInstall.value = null setupType.value = type + if (type === 'curseforge') { + modpackSelection.value = null + modpackFile.value = null + modpackFilePath.value = null + selectedLoader.value = null + selectedLoaderVersion.value = null + selectedGameVersion.value = null + modal.value?.setStage('curseforge') + return + } if (type === 'modpack') { selectedLoader.value = null selectedLoaderVersion.value = null @@ -652,6 +671,8 @@ export function createCreationFlowContext( } function finish() { + // CurseForge installation is intentionally disconnected until backend support is added. + if (setupType.value === 'curseforge') return if (finishDisabled.value) return debug('finish() called, state:', { @@ -797,6 +818,9 @@ export function createCreationFlowContext( modpackSelection, modpackFile, modpackFilePath, + curseforgeUrl, + curseforgeModpackFile, + curseforgeServerPackFile, projectInstall, projectSearchProjectId, projectSearchOptions, diff --git a/packages/ui/src/components/flows/creation-flow-modal/curseforge.ts b/packages/ui/src/components/flows/creation-flow-modal/curseforge.ts new file mode 100644 index 0000000000..163e81cd69 --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/curseforge.ts @@ -0,0 +1,78 @@ +import { defineMessages } from '#ui/composables/i18n' + +export const curseforgeMessages = defineMessages({ + title: { + id: 'creation-flow.curseforge.title', + defaultMessage: 'Upload a CurseForge modpack', + }, + description: { + id: 'creation-flow.curseforge.description', + defaultMessage: 'Use a modpack that isn’t available on Modrinth.', + }, + linkLabel: { + id: 'creation-flow.curseforge.link-label', + defaultMessage: 'CurseForge modpack link', + }, + linkDescription: { + id: 'creation-flow.curseforge.link-description', + defaultMessage: 'You can use a link to a modpack or a specific modpack version.', + }, + invalidLink: { + id: 'creation-flow.curseforge.invalid-link', + defaultMessage: 'Enter a CurseForge Minecraft modpack or modpack version link.', + }, + orUpload: { + id: 'creation-flow.curseforge.or-upload', + defaultMessage: 'Or upload it yourself', + }, + fileLabel: { + id: 'creation-flow.curseforge.file-label', + defaultMessage: 'CurseForge Modpack file', + }, + selectedFileLabel: { + id: 'creation-flow.curseforge.selected-file-label', + defaultMessage: 'Modpack file', + }, + fileDescription: { + id: 'creation-flow.curseforge.file-description', + defaultMessage: + 'You’ll need the regular modpack file, and can optionally add a server pack after if we can’t find one.', + }, + serverPackLabel: { + id: 'creation-flow.curseforge.server-pack-label', + defaultMessage: 'Server pack (optional)', + }, + serverPackDescription: { + id: 'creation-flow.curseforge.server-pack-description', + defaultMessage: + 'You can optionally add a server pack to make it easier to determine what needs to be installed on the server.', + }, + uploadPrompt: { + id: 'creation-flow.curseforge.upload-prompt', + defaultMessage: 'Drag and drop file or click to browse', + }, + removeFile: { + id: 'creation-flow.curseforge.remove-file', + defaultMessage: 'Remove {filename}', + }, + installationUnavailable: { + id: 'creation-flow.curseforge.installation-unavailable', + defaultMessage: 'CurseForge installation is not available yet.', + }, +}) + +export function isCurseForgeModpackUrl(value: string): boolean { + try { + const url = new URL(value.trim()) + return ( + url.protocol === 'https:' && + (url.hostname === 'curseforge.com' || url.hostname === 'www.curseforge.com') && + !url.username && + !url.password && + !url.port && + /^\/(?:minecraft\/)?modpacks\/[^/]+(?:\/files\/\d+)?\/?$/.test(url.pathname) + ) + } catch { + return false + } +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/curseforge-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/curseforge-stage.ts new file mode 100644 index 0000000000..c4c1218fda --- /dev/null +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/curseforge-stage.ts @@ -0,0 +1,40 @@ +import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets' +import { markRaw } from 'vue' + +import { commonMessages } from '#ui/utils/common-messages' + +import type { StageConfigInput } from '../../../base' +import CurseForgeStage from '../components/curseforge-stage/index.vue' +import { type CreationFlowContextValue, flowTypeHeadingMessages } from '../creation-flow-context' +import { isCurseForgeModpackUrl } from '../curseforge' + +function isForwardBlocked(ctx: CreationFlowContextValue): boolean { + return ( + ctx.finishDisabled.value || + (!ctx.curseforgeModpackFile.value && !isCurseForgeModpackUrl(ctx.curseforgeUrl.value)) + ) +} + +export const stageConfig: StageConfigInput = { + id: 'curseforge', + title: (ctx) => ctx.formatMessage(flowTypeHeadingMessages[ctx.flowType]), + stageContent: markRaw(CurseForgeStage), + skip: (ctx) => ctx.setupType.value !== 'curseforge' || ctx.flowType === 'instance', + cannotNavigateForward: isForwardBlocked, + leftButtonConfig: (ctx) => ({ + label: ctx.formatMessage(commonMessages.backButton), + icon: ChevronLeftIcon, + onClick: () => ctx.modal.value?.setStage('setup-type'), + }), + rightButtonConfig: (ctx) => ({ + label: ctx.formatMessage(commonMessages.continueButton), + icon: ChevronRightIcon, + iconPosition: 'after' as const, + disabled: isForwardBlocked(ctx), + tooltip: ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined, + onClick: () => { + if (!isForwardBlocked(ctx)) ctx.modal.value?.setStage('final-config') + }, + }), + maxWidth: '544px', +} 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 index 610436eefd..de49154c73 100644 --- 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 @@ -24,6 +24,7 @@ export const stageConfig: StageConfigInput = { stageContent: markRaw(CustomSetupStage), skip: (ctx) => ctx.setupType.value === 'modpack' || + ctx.setupType.value === 'curseforge' || ctx.setupType.value === 'vanilla' || ctx.isImportMode.value, cannotNavigateForward: isForwardBlocked, 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 index 5f54e94589..6b791ec921 100644 --- 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 @@ -10,8 +10,10 @@ import { creationFlowMessages, flowTypeHeadingMessages, } from '../creation-flow-context' +import { curseforgeMessages } from '../curseforge' function isForwardBlocked(ctx: CreationFlowContextValue): boolean { + if (ctx.setupType.value === 'curseforge') return true if (ctx.flowType === 'world' && !ctx.worldName.value.trim()) return true if (ctx.setupType.value === 'vanilla' && !ctx.selectedGameVersion.value) return true return false @@ -29,7 +31,9 @@ export const stageConfig: StageConfigInput = { icon: LeftArrowIcon, disabled: ctx.loading.value, onClick: () => { - if (ctx.onBack) { + if (ctx.setupType.value === 'curseforge') { + ctx.modal.value?.setStage('curseforge') + } else if (ctx.onBack) { ctx.onBack() } else { ctx.modal.value?.prevStage() @@ -61,7 +65,12 @@ export const stageConfig: StageConfigInput = { disabled: isForwardBlocked(ctx) || ctx.isBackingUp.value || (isFinish && ctx.finishDisabled.value), loading: isFinish && ctx.loading.value, - tooltip: isFinish && ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined, + tooltip: + ctx.setupType.value === 'curseforge' + ? ctx.formatMessage(curseforgeMessages.installationUnavailable) + : isFinish && ctx.finishDisabled.value + ? ctx.finishDisabledTooltip.value + : undefined, onClick: () => { if (isFinish) { ctx.finish() 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 index c990106dff..b072652f09 100644 --- a/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/index.ts @@ -1,5 +1,6 @@ import type { StageConfigInput } from '../../../base' import type { CreationFlowContextValue } from '../creation-flow-context' +import { stageConfig as curseforgeStageConfig } from './curseforge-stage' import { stageConfig as customSetupStageConfig } from './custom-setup-stage' import { stageConfig as finalConfigStageConfig } from './final-config-stage' import { stageConfig as importInstanceStageConfig } from './import-instance-stage' @@ -10,6 +11,7 @@ export const stageConfigs: StageConfigInput[] = [ setupTypeStageConfig, importInstanceStageConfig, customSetupStageConfig, + curseforgeStageConfig, finalConfigStageConfig, inviteFriendsStageConfig, ] diff --git a/packages/ui/src/components/servers/admonitions/CurseForgeFilesAdmonition.vue b/packages/ui/src/components/servers/admonitions/CurseForgeFilesAdmonition.vue new file mode 100644 index 0000000000..8a6f2a9966 --- /dev/null +++ b/packages/ui/src/components/servers/admonitions/CurseForgeFilesAdmonition.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue index bdb6ecbdaa..6fcdf3bd11 100644 --- a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue +++ b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue @@ -19,14 +19,23 @@ import { useServerPlayerMembers } from '#ui/layouts/wrapped/hosting/manage/[id]/ import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers' import BackupAdmonition, { type BackupAdmonitionEntry } from './BackupAdmonition.vue' +import CurseForgeFilesAdmonition from './CurseForgeFilesAdmonition.vue' import FileOperationAdmonition from './FileOperationAdmonition.vue' import ShareUpdateAdmonition from './ShareUpdateAdmonition.vue' import UploadAdmonition from './UploadAdmonition.vue' defineOptions({ inheritAttrs: false }) +const props = withDefaults( + defineProps<{ + curseforgeFileWarningCount?: number + }>(), + { curseforgeFileWarningCount: 0 }, +) + const emit = defineEmits<{ 'installation-retry': [] + 'review-curseforge-files': [] }>() const { formatMessage } = useVIntl() @@ -176,6 +185,7 @@ type ServerAdmonitionItem = StackedAdmonitionItem & { | { kind: 'busy-content' } | { kind: 'busy-files' } | { kind: 'share-update' } + | { kind: 'curseforge-files' } ) const showInstallingBanner = computed(() => { @@ -211,6 +221,17 @@ function backupPriority(entry: BackupAdmonitionEntry): number { const stackItems = computed(() => { const out: ServerAdmonitionItem[] = [] let sortIndex = 0 + const curseforgeWarningId = `curseforge-files:${ctx.worldId.value}:${props.curseforgeFileWarningCount}` + if (props.curseforgeFileWarningCount > 0 && !dismissedIds.has(curseforgeWarningId)) { + out.push({ + id: curseforgeWarningId, + type: 'warning', + dismissible: true, + kind: 'curseforge-files', + priority: 1, + sortIndex: sortIndex++, + }) + } if (showInstallingBanner.value) { const failed = ctx.installation.value?.status === 'failed' @@ -382,6 +403,8 @@ async function onDismissAll() { if (!it.dismissible) continue if (it.kind === 'installing') { onInstallationDismiss() + } else if (it.kind === 'curseforge-files') { + dismissedIds.add(it.id) } else if (it.kind === 'fs-op' && it.op.id) { const { op } = it if (op.state === 'done' || op.state?.startsWith('fail')) { @@ -416,8 +439,15 @@ function onInstallationDismiss() { @dismiss-all="onDismissAll" >