Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/assets/generated-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type IconComponent = FunctionalComponent<SVGAttributes>

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'
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions packages/assets/icons/anvil.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions packages/ui/src/components/base/DropzoneFileInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
</div>

<div class="flex flex-col items-center justify-center gap-1 text-contrast text-center">
<div class="text-contrast font-medium text-pretty">
<div v-if="primaryPrompt" class="text-contrast font-medium text-pretty">
{{ primaryPrompt }}
</div>
<span class="text-primary text-sm text-pretty">
Expand All @@ -39,7 +39,7 @@
:multiple="multiple"
:accept="accept"
:disabled="disabled"
class="hidden"
class="sr-only"
@change="handleChange"
/>
</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
</Accordion>

<InlineBackupCreator
v-if="ctx.flowType === 'reset-server'"
v-if="ctx.flowType === 'reset-server' && ctx.setupType.value !== 'curseforge'"
ref="backupCreator"
:backup-name="formatMessage(messages.beforeResetServerBackupName)"
hide-shift-click-hint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@
:description="formatMessage(messages.customSetupDescription)"
@click="setSetupType('custom')"
/>
<BigOptionButton
v-if="ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server'"
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
:disabled="ctx.navigating.value || ctx.finishDisabled.value"
:icon="AnvilIcon"
:title="formatMessage(curseforgeMessages.title)"
:description="formatMessage(curseforgeMessages.description)"
@click="ctx.setSetupType('curseforge')"
/>
<BigOptionButton
:disabled="ctx.navigating.value"
:icon="BoxIcon"
Expand All @@ -122,6 +131,7 @@

<script setup lang="ts">
import {
AnvilIcon,
BoxesIcon,
BoxIcon,
BoxImportIcon,
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<div
v-if="file"
class="flex h-10 min-w-0 items-center gap-2 rounded-[14px] border border-solid border-surface-5 bg-surface-4 px-4"
>
<FileIcon class="size-5 shrink-0 text-primary" aria-hidden="true" />
<span class="min-w-0 flex-1 truncate font-semibold text-contrast" :title="file.name">
{{ file.name }}
</span>
<IconButton
type="quiet"
size="sm"
:label="formatMessage(messages.removeFile, { filename: file.name })"
@click="file = null"
>
<XIcon aria-hidden="true" />
</IconButton>
</div>
<DropzoneFileInput
v-else
size="small"
accept=".zip"
:multiple="false"
:primary-prompt="null"
:secondary-prompt="formatMessage(messages.uploadPrompt)"
class="!rounded-[20px] !border-[1.5px] !border-surface-4 !bg-surface-2 focus-within:outline focus-within:outline-2 focus-within:outline-brand"
@change="file = $event[0] ?? null"
/>
</template>

<script setup lang="ts">
import { FileIcon, XIcon } from '@modrinth/assets'

import { IconButton } from '#ui/components/base/buttons'
import DropzoneFileInput from '#ui/components/base/DropzoneFileInput.vue'
import { useVIntl } from '#ui/composables/i18n'

import { curseforgeMessages as messages } from '../../curseforge'

const file = defineModel<File | null>({ required: true })
const { formatMessage } = useVIntl()
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<template>
<div class="flex flex-col gap-6">
<template v-if="!ctx.curseforgeModpackFile.value">
<div class="flex flex-col gap-2">
<label :for="linkId" class="font-semibold text-contrast">
{{ formatMessage(messages.linkLabel) }}
</label>
<Input
:id="linkId"
v-model="ctx.curseforgeUrl.value"
:icon="LinkIcon"
type="url"
size="medium"
input-class="text-sm"
placeholder="https://www.curseforge.com/modpacks/all-the-mods-10"
:error="invalidUrl"
:aria-describedby="`${linkId}-description`"
/>
<p
:id="`${linkId}-description`"
class="m-0 leading-6"
:class="invalidUrl ? 'text-red' : 'text-primary'"
>
{{ formatMessage(invalidUrl ? messages.invalidLink : messages.linkDescription) }}
</p>
</div>
<div class="flex items-center gap-4">
<div class="h-px flex-1 bg-surface-5" />
<span class="font-medium text-primary">{{ formatMessage(messages.orUpload) }}</span>
<div class="h-px flex-1 bg-surface-5" />
</div>
</template>

<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{
formatMessage(
ctx.curseforgeModpackFile.value ? messages.selectedFileLabel : messages.fileLabel,
)
}}
</span>
<ArchiveInput v-model="ctx.curseforgeModpackFile.value" />
<p v-if="!ctx.curseforgeModpackFile.value" class="m-0 leading-6 text-primary">
{{ formatMessage(messages.fileDescription) }}
</p>
</div>

<div v-if="ctx.curseforgeModpackFile.value" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
<IntlFormatted :message-id="messages.serverPackLabel">
<template #optional="{ children }">
<span class="text-primary"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</span>
<ArchiveInput v-model="ctx.curseforgeServerPackFile.value" />
<p class="m-0 leading-6 text-primary">
{{ formatMessage(messages.serverPackDescription) }}
</p>
</div>
</div>
</template>

<script setup lang="ts">
import { LinkIcon } from '@modrinth/assets'
import { computed, useId, watch } from 'vue'

import Input from '#ui/components/base/inputs/Input.vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import { useVIntl } from '#ui/composables/i18n'

import { injectCreationFlowContext } from '../../creation-flow-context'
import { curseforgeMessages as messages, isCurseForgeModpackUrl } from '../../curseforge'
import ArchiveInput from './archive-input.vue'

const ctx = injectCreationFlowContext()
const { formatMessage } = useVIntl()
const linkId = useId()
const invalidUrl = computed(
() => !!ctx.curseforgeUrl.value.trim() && !isCurseForgeModpackUrl(ctx.curseforgeUrl.value),
)

watch(ctx.curseforgeModpackFile, (file) => {
ctx.curseforgeServerPackFile.value = null
if (file) ctx.curseforgeUrl.value = ''
})
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -206,6 +206,9 @@ export interface CreationFlowContextValue {
modpackSelection: Ref<ModpackSelection | null>
modpackFile: Ref<File | null>
modpackFilePath: Ref<string | null>
curseforgeUrl: Ref<string>
curseforgeModpackFile: Ref<File | null>
curseforgeServerPackFile: Ref<File | null>
projectInstall: Ref<ProjectInstallSelection | null>

// Project search state (persisted across stage navigation)
Expand Down Expand Up @@ -392,6 +395,9 @@ export function createCreationFlowContext(
const modpackSelection = ref<ModpackSelection | null>(null)
const modpackFile = ref<File | null>(null)
const modpackFilePath = ref<string | null>(null)
const curseforgeUrl = ref('')
const curseforgeModpackFile = ref<File | null>(null)
const curseforgeServerPackFile = ref<File | null>(null)
const projectInstall = ref<ProjectInstallSelection | null>(null)

// Project search state (persisted across stage navigation)
Expand Down Expand Up @@ -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 = []
Expand All @@ -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
Expand Down Expand Up @@ -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:', {
Expand Down Expand Up @@ -797,6 +818,9 @@ export function createCreationFlowContext(
modpackSelection,
modpackFile,
modpackFilePath,
curseforgeUrl,
curseforgeModpackFile,
curseforgeServerPackFile,
projectInstall,
projectSearchProjectId,
projectSearchOptions,
Expand Down
78 changes: 78 additions & 0 deletions packages/ui/src/components/flows/creation-flow-modal/curseforge.ts
Original file line number Diff line number Diff line change
@@ -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>(optional)</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
}
}
Loading
Loading