mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +00:00
refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes
* feat: move files tab to shared layout structure
* fix: qa
* fix: qa
* fix: bugs
* fix: lint
* fix: admonition cleanup with progress + actions
* fix: cleanup
* fix: modals
* fix: admon title
* fix: i18n standard
* fix: lint + i18n pass
* fix: remove transition
* fix: type errors
* feat: files tab in app
* fix: qa
* fix: backup item minmax
* fix: use ContentPageHeader for server panel
* fix: lint
* fix: lint
* fix: lint
* feat: page leave safety
* fix: lint
* fix: cargo fmt fix
* fix: blank in prod
* fix: content card table stuff
* Revert "fix: blank in prod"
This reverts commit 74758fe185.
* fix: import
* feat: browse worlds/servers flow
* fix: worlds tab parity with content tab
* fix: perf bug + shader filter pill copy
* feat: singleplayer filter
* fix: ordering
* fix: breadcrumbs
* fix: lint
* fix: qa
* feat: store server proj id when adding to a non-linked instance
* fix: lint
* fix: i18n + qa
* fix: conflict
* qa: already installed modal + placeholders not server-specific
* fix: qa
* fix: add + edit server modals
* fix: qa
* fix: security
* fix: devin flags
* fix: lint
* chore: change file to break build cache
* fix: admon
* fix: import path stuff
* feat: qa
* fix: fmt fmt idiot
---------
Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header, { type })" max-width="500px">
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(fileValidationMessages.nameLabel)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="createInput"
|
||||
v-model="itemName"
|
||||
:placeholder="
|
||||
formatMessage(
|
||||
type === 'file' ? messages.placeholderFile : messages.placeholderDirectory,
|
||||
)
|
||||
"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-sm text-red">{{ error }}</div>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error && submitted" @click="handleSubmit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
{{ formatMessage(messages.createButton, { type }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { fileValidationMessages } from './file-validation-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.create-modal.header',
|
||||
defaultMessage: 'Create a {type, select, directory {folder} other {file}}',
|
||||
},
|
||||
placeholderFile: {
|
||||
id: 'files.create-modal.placeholder-file',
|
||||
defaultMessage: 'e.g. config.yml',
|
||||
},
|
||||
placeholderDirectory: {
|
||||
id: 'files.create-modal.placeholder-directory',
|
||||
defaultMessage: 'e.g. my-folder',
|
||||
},
|
||||
createButton: {
|
||||
id: 'files.create-modal.create-button',
|
||||
defaultMessage: 'Create {type, select, directory {folder} other {file}}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'file' | 'directory'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [name: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const createInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return formatMessage(fileValidationMessages.nameRequired)
|
||||
}
|
||||
if (props.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidFile)
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidDirectory)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('create', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
itemName.value = ''
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
createInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="danger" :header="formatMessage(messages.header)" max-width="500px">
|
||||
<Admonition type="critical" class="md:min-w-[400px]">
|
||||
<template #header>{{ formatMessage(messages.deletingName, { name: item?.name }) }}</template>
|
||||
{{ formatMessage(messages.deleteWarning, { type: item?.type }) }}
|
||||
</Admonition>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="handleSubmit">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.delete-modal.header',
|
||||
defaultMessage: 'Delete file',
|
||||
},
|
||||
deletingName: {
|
||||
id: 'files.delete-modal.deleting-name',
|
||||
defaultMessage: 'Deleting "{name}"',
|
||||
},
|
||||
deleteWarning: {
|
||||
id: 'files.delete-modal.warning',
|
||||
defaultMessage:
|
||||
'{type, select, directory {This folder and all its contents will be permanently deleted. This action cannot be undone.} other {This file will be permanently deleted. This action cannot be undone.}}',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('delete')
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { type: item?.type })"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.currentLocation)
|
||||
}}</span>
|
||||
<span class="text-secondary">{{ `${currentPath}/${item?.name}`.replace('//', '/') }}</span>
|
||||
</div>
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.destinationPath)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="destinationInput"
|
||||
v-model="destination"
|
||||
:placeholder="formatMessage(messages.destinationPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleSubmit">
|
||||
<RightArrowIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.moveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.move-modal.header',
|
||||
defaultMessage: '{type, select, directory {Move folder} other {Move file}}',
|
||||
},
|
||||
currentLocation: {
|
||||
id: 'files.move-modal.current-location',
|
||||
defaultMessage: 'Current location',
|
||||
},
|
||||
destinationPath: {
|
||||
id: 'files.move-modal.destination-path',
|
||||
defaultMessage: 'Destination path',
|
||||
},
|
||||
destinationPlaceholder: {
|
||||
id: 'files.move-modal.destination-placeholder',
|
||||
defaultMessage: 'e.g. /my-folder',
|
||||
},
|
||||
})
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
currentPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
move: [destination: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const destination = ref('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const path = destination.value.replace('//', '/')
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
emit('move', normalized)
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
destination.value = ''
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
destinationInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { name: item?.name })"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.newNameLabel) }}</span>
|
||||
<StyledInput ref="renameInput" v-model="itemName" wrapper-class="w-full" />
|
||||
<div v-if="submitted && error" class="text-sm text-red">{{ error }}</div>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error && submitted" @click="handleSubmit">
|
||||
<EditIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.renameButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
import { fileValidationMessages } from './file-validation-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.rename-modal.header',
|
||||
defaultMessage: 'Rename {name}',
|
||||
},
|
||||
newNameLabel: {
|
||||
id: 'files.rename-modal.new-name-label',
|
||||
defaultMessage: 'New name',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
rename: [newName: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const renameInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return formatMessage(fileValidationMessages.nameRequired)
|
||||
}
|
||||
if (props.item?.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidFile)
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidDirectory)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('rename', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = (item: { name: string; type: string }) => {
|
||||
itemName.value = item.name
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
renameInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="warning" :header="formatMessage(messages.header)" max-width="500px">
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.body) }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="handleDiscard">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.discard) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="handleSave">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.unsaved-changes-modal.header',
|
||||
defaultMessage: 'Unsaved changes',
|
||||
},
|
||||
body: {
|
||||
id: 'files.unsaved-changes-modal.body',
|
||||
defaultMessage:
|
||||
'You have unsaved changes that will be lost if you leave. Would you like to save before leaving?',
|
||||
},
|
||||
discard: {
|
||||
id: 'files.unsaved-changes-modal.discard',
|
||||
defaultMessage: 'Discard',
|
||||
},
|
||||
})
|
||||
|
||||
export type UnsavedChangesResult = 'cancel' | 'discard' | 'save'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
let resolvePromise: ((value: UnsavedChangesResult) => void) | null = null
|
||||
|
||||
function prompt(): Promise<UnsavedChangesResult> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
modal.value?.show()
|
||||
})
|
||||
}
|
||||
|
||||
function resolve(result: UnsavedChangesResult) {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(result)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
resolve('cancel')
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
resolve('discard')
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
resolve('save')
|
||||
}
|
||||
|
||||
defineExpose({ prompt })
|
||||
</script>
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :closable="true" no-padding>
|
||||
<div class="max-w-[500px]">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<Admonition type="warning" :header="formatMessage(messages.warningHeader)">
|
||||
<span>
|
||||
<template v-if="hasMany">
|
||||
{{ formatMessage(messages.overwriteManyWarning) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(messages.overwriteWarning, { count: files.length }) }}
|
||||
</template>
|
||||
</span>
|
||||
</Admonition>
|
||||
|
||||
<div v-if="files.length" class="flex gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<MinusIcon />
|
||||
{{ formatMessage(messages.overwrittenCount, { count: files.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="files.length"
|
||||
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
|
||||
>
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file"
|
||||
class="grid grid-cols-[auto_auto_1fr] items-center min-h-10 h-10 gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-between">
|
||||
<div class="w-[1px] h-2"></div>
|
||||
<MinusIcon class="text-red" />
|
||||
<div
|
||||
:class="index === files.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
|
||||
class="w-[1px] h-2 relative top-1"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-sm shrink-0 whitespace-nowrap">{{
|
||||
formatMessage(messages.overwrittenLabel)
|
||||
}}</span>
|
||||
<span
|
||||
v-tooltip="file"
|
||||
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ file }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleProceed">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.overwriteButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, MinusIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.conflict-modal.header',
|
||||
defaultMessage: 'Extract summary',
|
||||
},
|
||||
warningHeader: {
|
||||
id: 'files.conflict-modal.warning-header',
|
||||
defaultMessage: 'Files will be overwritten',
|
||||
},
|
||||
overwriteManyWarning: {
|
||||
id: 'files.conflict-modal.overwrite-many-warning',
|
||||
defaultMessage:
|
||||
'Over 100 files will be overwritten if you proceed with extraction; here are some of them.',
|
||||
},
|
||||
overwriteWarning: {
|
||||
id: 'files.conflict-modal.overwrite-warning',
|
||||
defaultMessage:
|
||||
'The following {count} files already exist on your server, and will be overwritten if you proceed with extraction.',
|
||||
},
|
||||
overwrittenCount: {
|
||||
id: 'files.conflict-modal.overwritten-count',
|
||||
defaultMessage: '{count} overwritten',
|
||||
},
|
||||
overwrittenLabel: {
|
||||
id: 'files.conflict-modal.overwritten-label',
|
||||
defaultMessage: 'Overwritten',
|
||||
},
|
||||
overwriteButton: {
|
||||
id: 'files.conflict-modal.overwrite-button',
|
||||
defaultMessage: 'Overwrite',
|
||||
},
|
||||
})
|
||||
|
||||
const path = ref('')
|
||||
const files = ref<string[]>([])
|
||||
|
||||
const emit = defineEmits<{
|
||||
proceed: [path: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const hasMany = computed(() => files.value.length > 100)
|
||||
|
||||
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||
path.value = zipPath
|
||||
files.value = conflictingFiles
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const handleProceed = () => {
|
||||
hide()
|
||||
emit('proceed', path.value)
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="cf ? formatMessage(messages.cfHeader) : formatMessage(messages.zipHeader)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 md:w-[700px]" @submit.prevent="handleSubmit">
|
||||
<!-- CurseForge stepper cards -->
|
||||
<div v-if="cf" class="flex gap-4">
|
||||
<div
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
class="flex flex-1 flex-col gap-2 overflow-clip rounded-[20px] bg-surface-2 p-3"
|
||||
>
|
||||
<span
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-4 font-medium text-contrast"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<div class="font-semibold leading-snug text-contrast">
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div class="text-sm leading-relaxed text-secondary">
|
||||
{{ step.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL input -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label v-if="cf" class="text-base font-semibold text-contrast">{{
|
||||
formatMessage(messages.enterLink)
|
||||
}}</label>
|
||||
<div v-else class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.zipDescription) }}
|
||||
</div>
|
||||
<StyledInput
|
||||
v-model="url"
|
||||
:icon="LinkIcon"
|
||||
type="url"
|
||||
:placeholder="
|
||||
cf
|
||||
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
|
||||
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
|
||||
"
|
||||
:disabled="submitted"
|
||||
:error="touched && !!error"
|
||||
autocomplete="off"
|
||||
@focus="touched = true"
|
||||
/>
|
||||
<div v-if="touched && error" class="text-xs text-red">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline backup creator -->
|
||||
<InlineBackupCreator
|
||||
:backup-name="formatMessage(messages.backupName)"
|
||||
hide-shift-click-hint
|
||||
@update:buttons-disabled="backupInProgress = $event"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex w-full items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" class="!border !border-surface-4" @click="hide">
|
||||
<XIcon />
|
||||
{{
|
||||
submitted
|
||||
? formatMessage(commonMessages.closeButton)
|
||||
: formatMessage(commonMessages.cancelButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="error"
|
||||
:disabled="submitted || !!error || backupInProgress"
|
||||
type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<SpinnerIcon v-if="submitted" class="animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
submitted
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: formatMessage(messages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
LinkIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers/api-client'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from '../../../content-tab/components/modals/InlineBackupCreator.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
cfHeader: {
|
||||
id: 'files.zip-url-modal.cf-header',
|
||||
defaultMessage: 'Install a CurseForge modpack',
|
||||
},
|
||||
zipHeader: {
|
||||
id: 'files.zip-url-modal.zip-header',
|
||||
defaultMessage: 'Uploading .zip contents from URL',
|
||||
},
|
||||
enterLink: {
|
||||
id: 'files.zip-url-modal.enter-link',
|
||||
defaultMessage: 'Enter link',
|
||||
},
|
||||
zipDescription: {
|
||||
id: 'files.zip-url-modal.zip-description',
|
||||
defaultMessage: 'Copy and paste the direct download URL of a .zip file.',
|
||||
},
|
||||
installButton: {
|
||||
id: 'files.zip-url-modal.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
stepFindTitle: {
|
||||
id: 'files.zip-url-modal.step-find-title',
|
||||
defaultMessage: 'Find the modpack',
|
||||
},
|
||||
stepFindDescription: {
|
||||
id: 'files.zip-url-modal.step-find-description',
|
||||
defaultMessage: 'Browse CurseForge and locate the modpack you want.',
|
||||
},
|
||||
stepSelectTitle: {
|
||||
id: 'files.zip-url-modal.step-select-title',
|
||||
defaultMessage: 'Select a version',
|
||||
},
|
||||
stepSelectDescription: {
|
||||
id: 'files.zip-url-modal.step-select-description',
|
||||
defaultMessage: 'Go to the "Files" tab and pick the version to install.',
|
||||
},
|
||||
stepCopyTitle: {
|
||||
id: 'files.zip-url-modal.step-copy-title',
|
||||
defaultMessage: 'Copy the URL',
|
||||
},
|
||||
stepCopyDescription: {
|
||||
id: 'files.zip-url-modal.step-copy-description',
|
||||
defaultMessage: 'Copy the version page URL and paste it below.',
|
||||
},
|
||||
errorUrlRequired: {
|
||||
id: 'files.zip-url-modal.error-url-required',
|
||||
defaultMessage: 'URL is required.',
|
||||
},
|
||||
errorCfUrl: {
|
||||
id: 'files.zip-url-modal.error-cf-url',
|
||||
defaultMessage: 'URL must be a CurseForge modpack version URL.',
|
||||
},
|
||||
errorUrlInvalid: {
|
||||
id: 'files.zip-url-modal.error-url-invalid',
|
||||
defaultMessage: 'URL must be valid.',
|
||||
},
|
||||
cfNotFoundTitle: {
|
||||
id: 'files.zip-url-modal.cf-not-found-title',
|
||||
defaultMessage: 'CurseForge modpack not found',
|
||||
},
|
||||
cfNotFoundText: {
|
||||
id: 'files.zip-url-modal.cf-not-found-text',
|
||||
defaultMessage: 'Could not find CurseForge modpack at that URL.',
|
||||
},
|
||||
installFailedTitle: {
|
||||
id: 'files.zip-url-modal.install-failed-title',
|
||||
defaultMessage: 'Installation failed',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'files.zip-url-modal.unknown-error',
|
||||
defaultMessage: 'An unknown error occurred',
|
||||
},
|
||||
backupName: {
|
||||
id: 'files.zip-url-modal.backup-name',
|
||||
defaultMessage: 'CurseForge modpack install',
|
||||
},
|
||||
})
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: SearchIcon,
|
||||
title: formatMessage(messages.stepFindTitle),
|
||||
description: formatMessage(messages.stepFindDescription),
|
||||
},
|
||||
{
|
||||
icon: FileTextIcon,
|
||||
title: formatMessage(messages.stepSelectTitle),
|
||||
description: formatMessage(messages.stepSelectDescription),
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
title: formatMessage(messages.stepCopyTitle),
|
||||
description: formatMessage(messages.stepCopyDescription),
|
||||
},
|
||||
]
|
||||
|
||||
const cf = ref(false)
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const url = ref('')
|
||||
const submitted = ref(false)
|
||||
const touched = ref(false)
|
||||
const backupInProgress = ref(false)
|
||||
|
||||
const trimmedUrl = computed(() => url.value.trim())
|
||||
|
||||
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/
|
||||
|
||||
const error = computed(() => {
|
||||
if (trimmedUrl.value.length === 0) {
|
||||
return formatMessage(messages.errorUrlRequired)
|
||||
}
|
||||
if (cf.value && !regex.test(trimmedUrl.value)) {
|
||||
return formatMessage(messages.errorCfUrl)
|
||||
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
|
||||
return formatMessage(messages.errorUrlInvalid)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
touched.value = true
|
||||
if (error.value) return
|
||||
|
||||
submitted.value = true
|
||||
try {
|
||||
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
|
||||
|
||||
if (!cf.value || dry.modpack_name) {
|
||||
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
|
||||
hide()
|
||||
} else {
|
||||
submitted.value = false
|
||||
addNotification({
|
||||
title: formatMessage(messages.cfNotFoundTitle),
|
||||
text: formatMessage(messages.cfNotFoundText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
submitted.value = false
|
||||
console.error('Error installing:', err)
|
||||
addNotification({
|
||||
title: formatMessage(messages.installFailedTitle),
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.unknownError),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const show = (isCf: boolean) => {
|
||||
cf.value = isCf
|
||||
url.value = ''
|
||||
submitted.value = false
|
||||
touched.value = false
|
||||
backupInProgress.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
modal.value?.$el?.querySelector('input')?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { defineMessages } from '#ui/composables/i18n'
|
||||
|
||||
export const fileValidationMessages = defineMessages({
|
||||
nameLabel: {
|
||||
id: 'files.validation.name-label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
nameRequired: {
|
||||
id: 'files.validation.name-required',
|
||||
defaultMessage: 'Name is required.',
|
||||
},
|
||||
nameInvalidFile: {
|
||||
id: 'files.validation.name-invalid-file',
|
||||
defaultMessage:
|
||||
'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.',
|
||||
},
|
||||
nameInvalidDirectory: {
|
||||
id: 'files.validation.name-invalid-directory',
|
||||
defaultMessage:
|
||||
'Name must contain only alphanumeric characters, dashes, underscores, or spaces.',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user