Compare commits

..
Author SHA1 Message Date
Calum H. (IMB11) f5a7ce6700 fix: race on renaming instance file 2026-06-29 16:11:40 +01:00
58 changed files with 461 additions and 1464 deletions
+7
View File
@@ -76,6 +76,13 @@ jobs:
env:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
+7
View File
@@ -78,6 +78,13 @@ jobs:
GIT_HASH: ${{ github.sha }}
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
- name: Check out code
+7
View File
@@ -81,6 +81,13 @@ jobs:
# blacksmith runner)
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
@@ -1,32 +1,28 @@
<script setup>
import { XIcon } from '@modrinth/assets'
import { WrenchIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
FileTreeSelect,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
import { PackageIcon } from '@/assets/icons'
import {
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
modpackNamePlaceholder: {
id: 'app.export-modal.modpack-name-placeholder',
defaultMessage: 'Modpack name',
@@ -43,7 +39,15 @@ const messages = defineMessages({
id: 'app.export-modal.description-placeholder',
defaultMessage: 'Enter modpack description...',
},
selectFilesLabel: {
id: 'app.export-modal.select-files-label',
defaultMessage: 'Configure which files are included in this export',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
includeFile: {
id: 'app.export-modal.include-file-accessibility-label',
defaultMessage: 'Include "{file}"?',
},
})
const props = defineProps({
@@ -55,9 +59,8 @@ const props = defineProps({
defineExpose({
show: () => {
resetExportState()
exportModal.value.show()
void initFiles().catch(handleError)
initFiles()
},
})
@@ -66,33 +69,62 @@ const nameInput = ref(props.instance.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const selectedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
const folders = ref([])
async function initFiles() {
const loadId = ++filesLoadId.value
const [filePaths, root] = await Promise.all([
get_pack_export_candidates(props.instance.id),
get_full_path(props.instance.id),
])
if (loadId !== filesLoadId.value) return
instanceRoot.value = root
const exportCandidates = await Promise.all(
filePaths.map((path) => buildExportCandidateItem(root, path)),
const initFiles = async () => {
const newFolders = new Map()
const sep = '/'
files.value = []
await get_pack_export_candidates(props.instance.id).then((filePaths) =>
filePaths
.map((folder) => ({
path: folder,
name: folder.split(sep).pop(),
selected:
folder.startsWith('mods') ||
folder.startsWith('datapacks') ||
folder.startsWith('resourcepacks') ||
folder.startsWith('shaderpacks') ||
folder.startsWith('config'),
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric') ||
folder.startsWith('__MACOSX'),
}))
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
if (newFolders.has(parent)) {
newFolders.get(parent).push(pathData)
} else {
newFolders.set(parent, [pathData])
}
} else {
files.value.push(pathData)
}
}),
)
if (loadId !== filesLoadId.value) return
files.value = exportCandidates
selectedFilePaths.value = files.value
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
.map((file) => file.path)
folders.value = [...newFolders.entries()].map(([name, value]) => [
{
name,
showingMore: false,
},
value,
])
}
await initFiles()
const exportPack = async () => {
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
folders.value.forEach((args) => {
args[1].forEach((child) => {
if (child.selected) {
filesToExport.push(child.path)
}
})
})
const outputPath = await save({
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
filters: [
@@ -107,7 +139,7 @@ const exportPack = async () => {
export_instance_mrpack(
props.instance.id,
outputPath,
selectedFilePaths.value,
filesToExport,
versionInput.value,
exportDescription.value,
nameInput.value,
@@ -115,176 +147,94 @@ const exportPack = async () => {
exportModal.value.hide()
}
}
function resetExportState() {
nameInput.value = props.instance.name
exportDescription.value = ''
versionInput.value = '1.0.0'
files.value = []
selectedFilePaths.value = []
fileTreeKey.value += 1
instanceRoot.value = ''
loadedDirectories.value = new Set()
}
async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
try {
const entries = await readDir(`${instanceRoot.value}/${path}`)
const childItems = await Promise.all(
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
)
if (loadId !== filesLoadId.value) return
appendExportItems(childItems)
} catch {
loadedDirectories.value.delete(path)
}
}
async function buildExportCandidateItem(instanceRoot, path) {
try {
const entries = await readDir(`${instanceRoot}/${path}`)
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
count: entries.length,
}
} catch {
return buildExportFileItem(instanceRoot, path)
}
}
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
const path = `${parentPath}/${entry.name}`
if (entry.isDirectory) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
}
}
return buildExportFileItem(instanceRoot, path)
}
async function buildExportFileItem(instanceRoot, path) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'file',
disabled: isExportCandidateDisabled(path),
size: metadata.size,
modified: metadata.modified,
}
}
function appendExportItems(items) {
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
for (const item of items) {
nextFiles.set(normalizeExportPath(item.path), item)
}
files.value = [...nextFiles.values()]
}
async function getExportCandidateMetadata(instanceRoot, path) {
try {
const metadata = await stat(`${instanceRoot}/${path}`)
return {
size: metadata.size,
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
}
} catch {
return {}
}
}
function normalizeExportPath(path) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function isDefaultSelectedExportCandidate(path) {
return (
path.startsWith('mods') ||
path.startsWith('datapacks') ||
path.startsWith('resourcepacks') ||
path.startsWith('shaderpacks') ||
path.startsWith('config')
)
}
function isExportCandidateDisabled(path) {
return (
path === 'profile.json' ||
path.startsWith('modrinth_logs') ||
path.startsWith('.fabric') ||
path.startsWith('__MACOSX')
)
}
</script>
<template>
<NewModal
ref="exportModal"
:header="formatMessage(messages.header)"
scrollable
width="46rem"
max-width="calc(100vw - 2rem)"
>
<div class="flex flex-col gap-4">
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<div class="flex flex-col gap-4 w-[40rem]">
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
<div class="labeled_input">
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-2 min-w-0">
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<div class="flex flex-col gap-2">
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
</div>
<FileTreeSelect
:key="fileTreeKey"
v-model="selectedFilePaths"
class="min-w-0"
:items="files"
@navigate="loadExportDirectory"
/>
</div>
<template #actions>
<Accordion
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
>
<template #title>
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
Configure which files are included in this export
</span>
</template>
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
<Accordion
class="flex flex-col"
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
>
<template #title>
<Checkbox
:model-value="children.every((child) => child.selected)"
:indeterminate="
!children.every((child) => child.selected) &&
children.some((child) => child.selected)
"
:description="formatMessage(messages.includeFile, { file: path.name })"
class="pl-4 py-2"
:disabled="children.every((x) => x.disabled)"
@update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue))
"
@click.stop
/>
<span class="ml-2 group-active:scale-95">{{ path.name }}/</span>
</template>
<div v-for="child in children" :key="child.path">
<Checkbox
v-model="child.selected"
:label="child.name"
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
:disabled="child.disabled"
/>
</div>
</Accordion>
</div>
<Checkbox
v-for="file in files"
:key="file.path"
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
/>
</div>
</Accordion>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
@@ -299,6 +249,6 @@ function isExportCandidateDisabled(path) {
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</div>
</ModalWrapper>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
@@ -301,12 +301,11 @@ const messages = defineMessages({
</ButtonStyled>
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchFriends)"
clearable
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
variant="outlined"
wrapper-class="flex-1"
@keyup.esc="search = ''"
/>
</template>
@@ -122,6 +122,9 @@
"app.export-modal.modpack-name-placeholder": {
"message": "إسم حزمة التعديل"
},
"app.export-modal.select-files-label": {
"message": "أختيار الملفات التي سيتم تصديرها"
},
"app.export-modal.version-number-label": {
"message": "رقم الإصدار"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Exportovat modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Zahrnout \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Název modpacku"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Název modpacku"
},
"app.export-modal.select-files-label": {
"message": "Nastavte, které soubory mají být do tohoto exportu zahrnuty"
},
"app.export-modal.version-number-label": {
"message": "Číslo verze"
},
@@ -164,12 +164,18 @@
"app.export-modal.header": {
"message": "Eksporter modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludere \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Navn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack navn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurer hvilke filer inkludere i denne eksport"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschliessen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesem Export miteinbezogen werden"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschließen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpackname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpackname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesen Export enthalten sind"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Export modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Include \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack name"
"message": "Modpack Name"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack name"
},
"app.export-modal.select-files-label": {
"message": "Configure which files are included in this export"
},
"app.export-modal.version-number-label": {
"message": "Version number"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos incluir en esta exportación"
},
"app.export-modal.version-number-label": {
"message": "Número de la versión"
},
@@ -182,12 +182,18 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos se incluirán en esta instancia"
},
"app.export-modal.version-number-label": {
"message": "Número de versión"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Vie modipaketti"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sisällytä \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modipaketin nimi"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modipaketin nimi"
},
"app.export-modal.select-files-label": {
"message": "Muuta mitkä tiedostot sisältyvät tähän vientiin"
},
"app.export-modal.version-number-label": {
"message": "Versio numero"
},
@@ -164,12 +164,18 @@
"app.export-modal.header": {
"message": "Iluwas ang modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Salihin ang \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Pangalan ng Modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Pangalan ng modpack"
},
"app.export-modal.select-files-label": {
"message": "Isaayos kung anong mga file ang isasali sa pagluwas"
},
"app.export-modal.version-number-label": {
"message": "Numero ng bersiyon"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Exporter le modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inclure « {file} » ?"
},
"app.export-modal.modpack-name-label": {
"message": "Nom du modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nom du modpack"
},
"app.export-modal.select-files-label": {
"message": "Configurez quels fichiers sont inclus dans cette exportation"
},
"app.export-modal.version-number-label": {
"message": "Numéro de version"
},
@@ -176,12 +176,18 @@
"app.export-modal.header": {
"message": "Modcsomag exportálása"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Tartalmaz „{file}” fájlokat?"
},
"app.export-modal.modpack-name-label": {
"message": "A modcsomag neve"
},
"app.export-modal.modpack-name-placeholder": {
"message": "A modcsomag neve"
},
"app.export-modal.select-files-label": {
"message": "Állítsd be, hogy mely fájlok kerüljenek bele az exportba"
},
"app.export-modal.version-number-label": {
"message": "Verziószám"
},
@@ -170,12 +170,18 @@
"app.export-modal.header": {
"message": "Ekspor paket mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Paket Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama paket mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan berkas mana yang disertakan dalam pengeksporan ini"
},
"app.export-modal.version-number-label": {
"message": "Nomor versi"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Esporta pacchetto"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Includere \"{file}\" nell'esportazione?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome del pacchetto"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome del pacchetto"
},
"app.export-modal.select-files-label": {
"message": "Seleziona i file da includere in questa esportazione"
},
"app.export-modal.version-number-label": {
"message": "Numero di versione"
},
@@ -164,12 +164,18 @@
"app.export-modal.header": {
"message": "모드팩 내보내기"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\"(을)를 포함할까요?"
},
"app.export-modal.modpack-name-label": {
"message": "모드팩 이름"
},
"app.export-modal.modpack-name-placeholder": {
"message": "모드팩 이름"
},
"app.export-modal.select-files-label": {
"message": "내보내기에 어느 파일이 포함될지 구성"
},
"app.export-modal.version-number-label": {
"message": "버전 구분"
},
@@ -155,12 +155,18 @@
"app.export-modal.header": {
"message": "Eksport pek mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Pek Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama pek mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan fail yang disertakan dalam eksport ini"
},
"app.export-modal.version-number-label": {
"message": "Nombor versi"
},
@@ -170,12 +170,18 @@
"app.export-modal.header": {
"message": "Exporteer modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Betrek \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Naam"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack naam"
},
"app.export-modal.select-files-label": {
"message": "Configureer welke bestanden bij deze export zijn betrokken"
},
"app.export-modal.version-number-label": {
"message": "Versie nummer"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Eksportuj paczkę modów"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Dołączyć \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nazwa paczki modów"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nazwa paczki modów"
},
"app.export-modal.select-files-label": {
"message": "Skonfiguruje jakie pliki zostaną załączone w tym eksporcie"
},
"app.export-modal.version-number-label": {
"message": "Numer wersji"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Exportar pacote de mods"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome do pacote de mods"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome do pacote de mods"
},
"app.export-modal.select-files-label": {
"message": "Configure quais arquivos serão incluídos na exportação"
},
"app.export-modal.version-number-label": {
"message": "Número da versão"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Экспорт сборки"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Добавить «{file}» в экспорт?"
},
"app.export-modal.modpack-name-label": {
"message": "Название сборки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Название сборки"
},
"app.export-modal.select-files-label": {
"message": "Выберите файлы для экспорта"
},
"app.export-modal.version-number-label": {
"message": "Номер версии"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Exportera modpaket"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludera \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketets namn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketets namn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurera vilka filer som inkluderas i denna export"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -170,12 +170,18 @@
"app.export-modal.header": {
"message": "ส่งออกแพ็กม็อด"
},
"app.export-modal.include-file-accessibility-label": {
"message": "รวมถึง \"{file}\" ใช่หรือไม่"
},
"app.export-modal.modpack-name-label": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.modpack-name-placeholder": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.select-files-label": {
"message": "กำหนดไฟล์ที่ต้องการจะส่งออก"
},
"app.export-modal.version-number-label": {
"message": "หมายเลขเวอร์ชัน"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "Mod paketini dışa aktar"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" dahil mi?"
},
"app.export-modal.modpack-name-label": {
"message": "Mod Paketi Adı"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Mod paketi adı"
},
"app.export-modal.select-files-label": {
"message": "Bu dışa aktarmaya hangi dosyaların dahil edileceğini yapılandırma"
},
"app.export-modal.version-number-label": {
"message": "Sürüm numarası"
},
@@ -170,12 +170,18 @@
"app.export-modal.header": {
"message": "Експортувати збірку"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Уключити «{file}»?"
},
"app.export-modal.modpack-name-label": {
"message": "Назва збірки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Назва збірки"
},
"app.export-modal.select-files-label": {
"message": "Змініть файли, які додані до експорту"
},
"app.export-modal.version-number-label": {
"message": "Номер версії"
},
@@ -170,12 +170,18 @@
"app.export-modal.header": {
"message": "Xuất modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Bao gồm \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Tên modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Tên modpack"
},
"app.export-modal.select-files-label": {
"message": "Cấu hình các tệp nào được bao gồm trong quá trình xuất phiên bản này"
},
"app.export-modal.version-number-label": {
"message": "Phiên bản"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "导出整合包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "包含 \"{file}\""
},
"app.export-modal.modpack-name-label": {
"message": "整合包名称"
},
"app.export-modal.modpack-name-placeholder": {
"message": "整合包名称"
},
"app.export-modal.select-files-label": {
"message": "配置此导出中包含哪些文件"
},
"app.export-modal.version-number-label": {
"message": "版本号"
},
@@ -203,12 +203,18 @@
"app.export-modal.header": {
"message": "匯出模組包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "要包含「{file}」嗎?"
},
"app.export-modal.modpack-name-label": {
"message": "模組包名稱"
},
"app.export-modal.modpack-name-placeholder": {
"message": "模組包名稱"
},
"app.export-modal.select-files-label": {
"message": "設定要包含在此匯出檔案中的檔案"
},
"app.export-modal.version-number-label": {
"message": "版本號碼"
},
@@ -676,7 +676,7 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
break
case 'copy_path': {
if (instance.value) {
const fullPath = await get_full_path(instance.value.id)
const fullPath = await get_full_path(instance.value?.path)
await navigator.clipboard.writeText(fullPath)
}
break
@@ -1443,7 +1443,7 @@ onMounted(() => {
props.instance &&
event.instance_id === props.instance.id &&
event.event === 'synced' &&
props.instance.install_stage === 'installed' &&
props.instance.install_stage !== 'pack_installing' &&
!isBulkOperating.value
) {
await initProjects()
+1 -6
View File
@@ -414,7 +414,7 @@ components:
- unknown
- signature
example: required-resource-pack
# https://github.com/modrinth/code/blob/main/apps/labrinth/src/routes/v2/version_creation.rs#L32-L76
# https://github.com/modrinth/labrinth/blob/master/src/routes/version_creation.rs#L27-L57
CreatableVersion:
allOf:
- $ref: '#/components/schemas/BaseVersion'
@@ -446,11 +446,6 @@ components:
- client_or_server_prefers_both
- unknown
description: The environment that this version is for.
file_types:
type: object
additionalProperties:
$ref: '#/components/schemas/FileTypeEnum'
description: A map of file parts to their associated file type, a file type is used for additional files such as sources jars.
required:
- file_parts
- project_id
@@ -41,38 +41,38 @@ export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
export const LIGHT_LEGEND_PALETTE = [
'hsl(152, 100%, 34%)',
'hsl(41, 79%, 46%)',
'hsl(203, 76%, 64%)',
'hsl(0, 93%, 62%)',
'hsl(143, 66%, 29%)',
'hsl(58, 89%, 25%)',
'hsl(311, 64%, 49%)',
'hsl(198, 91%, 32%)',
'hsl(12, 88%, 27%)',
'hsl(236, 61%, 60%)',
'hsl(102, 59%, 74%)',
'hsl(293, 76%, 79%)',
'hsl(67, 99%, 41%)',
'hsl(179, 100%, 50%)',
'hsl(102, 100%, 61%)',
'hsl(0, 100%, 32%)',
'hsl(26, 100%, 42%)',
'hsl(202, 100%, 35%)',
'hsl(327, 45%, 64%)',
'hsl(41, 100%, 45%)',
'hsl(250, 60%, 33%)',
'hsl(170, 43%, 47%)',
'hsl(330, 60%, 33%)',
'hsl(46, 100%, 36%)',
'hsl(167, 100%, 30%)',
'hsl(343, 38%, 45%)',
'hsl(222, 100%, 28%)',
'hsl(270, 62%, 60%)',
'hsl(32, 100%, 37%)',
'hsl(349, 57%, 51%)',
'hsl(191, 43%, 37%)',
]
export const DARK_LEGEND_PALETTE = [
'hsl(145, 78%, 48%)',
'hsl(41, 79%, 46%)',
'hsl(203, 76%, 64%)',
'hsl(0, 93%, 62%)',
'hsl(143, 66%, 29%)',
'hsl(58, 94%, 45%)',
'hsl(311, 64%, 49%)',
'hsl(198, 91%, 32%)',
'hsl(12, 88%, 27%)',
'hsl(236, 61%, 60%)',
'hsl(102, 59%, 74%)',
'hsl(293, 76%, 79%)',
'hsl(61, 92%, 33%)',
'hsl(179, 100%, 50%)',
'hsl(102, 100%, 61%)',
'hsl(0, 100%, 32%)',
'hsl(41, 100%, 50%)',
'hsl(202, 77%, 63%)',
'hsl(323, 66%, 72%)',
'hsl(56, 85%, 60%)',
'hsl(255, 92%, 80%)',
'hsl(12, 100%, 67%)',
'hsl(176, 58%, 56%)',
'hsl(60, 100%, 41%)',
'hsl(165, 80%, 38%)',
'hsl(341, 36%, 56%)',
'hsl(226, 60%, 49%)',
'hsl(252, 53%, 62%)',
'hsl(75, 59%, 50%)',
'hsl(195, 56%, 42%)',
'hsl(30, 59%, 56%)',
]
@@ -288,7 +288,6 @@ type PaletteRankEntry = {
key: string
label: string
total: number
excludedFromRank?: boolean
}
function formatDatasetTooltip(projectName: string | undefined): string | undefined {
@@ -331,12 +330,9 @@ function buildPaletteColorsByDownloadRank(
const colorsByKey = new Map<string, string>()
if (palette.length === 0) return colorsByKey
const compareEntries = (a: PaletteRankEntry, b: PaletteRankEntry) =>
b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key)
const rankedEntries = entries.filter((entry) => !entry.excludedFromRank).sort(compareEntries)
const excludedEntries = entries.filter((entry) => entry.excludedFromRank).sort(compareEntries)
const sortedEntries = [...rankedEntries, ...excludedEntries]
const sortedEntries = [...entries].sort(
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
)
sortedEntries.forEach((entry, index) => {
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
})
@@ -344,27 +340,6 @@ function buildPaletteColorsByDownloadRank(
return colorsByKey
}
function isExcludedFromPaletteRank(breakdownValues: readonly string[]): boolean {
return breakdownValues.some(
(value) =>
isUnknownAnalyticsBreakdownValue(value) || isNoDependentAnalyticsBreakdownValue(value),
)
}
function buildPaletteRankEntry(
key: string,
breakdownValues: readonly string[],
total: number,
formatLabel: (breakdownValues: readonly string[]) => string,
): PaletteRankEntry {
return {
key,
label: formatLabel(breakdownValues),
total,
excludedFromRank: isExcludedFromPaletteRank(breakdownValues),
}
}
export function getMetricValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
activeStat: AnalyticsDashboardStat,
@@ -524,14 +499,11 @@ export function buildChartDatasets(
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByBreakdown.keys()).map((breakdownKey) =>
buildPaletteRankEntry(
breakdownKey,
breakdownValuesByKey.get(breakdownKey) ?? [],
downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
formatChartBreakdownLabels,
),
),
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
})),
palette,
)
@@ -703,14 +675,11 @@ export function buildChartDatasets(
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) =>
buildPaletteRankEntry(
breakdownKey,
breakdownValuesByKey.get(breakdownKey) ?? [],
downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
formatChartBreakdownLabels,
),
),
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
})),
palette,
)
+29 -40
View File
@@ -183,24 +183,24 @@
>
<div class="flex flex-col gap-6">
<template v-if="auth.user.has_totp && twoFactorStep === 0">
<div class="flex flex-col gap-2.5">
<label for="two-factor-code">
<span class="text-md font-semibold text-contrast">{{
formatMessage(messages.twoFactorEnterCodeLabel)
}}</span>
<StyledInput
id="two-factor-code"
v-model="twoFactorCode"
:maxlength="11"
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
@keyup.enter="removeTwoFactor()"
/>
<span class="label__description">{{
formatMessage(messages.twoFactorEnterCodeDescription)
}}</span>
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
</div>
</label>
<StyledInput
id="two-factor-code"
v-model="twoFactorCode"
:maxlength="11"
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
@keyup.enter="removeTwoFactor()"
/>
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.manageTwoFactorModal.hide()">
@@ -222,13 +222,12 @@
<p class="m-0">
<IntlFormatted :message-id="messages.twoFactorSetupScan">
<template #authy-link="{ children }">
<a class="underline" href="https://authy.com/" target="_blank" rel="noreferrer">
<a href="https://authy.com/" target="_blank" rel="noreferrer">
<component :is="() => children" />
</a>
</template>
<template #microsoft-authenticator-link="{ children }">
<a
class="underline"
href="https://www.microsoft.com/en-us/security/mobile-authenticator-app"
target="_blank"
rel="noreferrer"
@@ -253,22 +252,25 @@
</p>
</template>
<template v-if="twoFactorStep === 1">
<div class="flex flex-col gap-2.5">
<label for="verify-code">
<span class="text-md font-semibold text-contrast">{{
formatMessage(messages.twoFactorVerifyCodeLabel)
}}</span>
<TwoFactorAuthCodeInput
ref="twoFactorCodeInput"
v-model="twoFactorCode"
@keyup.enter="verifyTwoFactorCode()"
/>
<span class="label__description">{{
formatMessage(messages.twoFactorVerifyCodeDescription)
}}</span>
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
</div>
</label>
<StyledInput
id="verify-code"
v-model="twoFactorCode"
:maxlength="6"
autocomplete="one-time-code"
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
@keyup.enter="verifyTwoFactorCode()"
/>
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
</template>
<template v-if="twoFactorStep === 2">
<p class="m-0">{{ formatMessage(messages.twoFactorBackupCodesIntro) }}</p>
@@ -476,7 +478,6 @@
</template>
<script setup>
import { nextTick, watch } from 'vue'
import {
CheckIcon,
DownloadIcon,
@@ -502,7 +503,6 @@ import {
NewModal,
StyledInput,
Table,
TwoFactorAuthCodeInput,
useVIntl,
} from '@modrinth/ui'
import KeyIcon from 'assets/icons/auth/key.svg'
@@ -878,10 +878,9 @@ const manageTwoFactorModal = ref()
const twoFactorSecret = ref(null)
const twoFactorFlow = ref(null)
const twoFactorStep = ref(0)
const twoFactorCodeInput = ref()
async function showTwoFactorModal() {
twoFactorStep.value = 0
twoFactorCode.value = ''
twoFactorCode.value = null
twoFactorIncorrect.value = false
if (auth.value.user.has_totp) {
manageTwoFactorModal.value.show()
@@ -891,6 +890,7 @@ async function showTwoFactorModal() {
twoFactorSecret.value = null
twoFactorFlow.value = null
backupCodes.value = []
manageTwoFactorModal.value.show()
startLoading()
try {
@@ -908,22 +908,11 @@ async function showTwoFactorModal() {
})
}
stopLoading()
manageTwoFactorModal.value.show()
}
const twoFactorIncorrect = ref(false)
const twoFactorCode = ref('')
const twoFactorCode = ref(null)
const backupCodes = ref([])
watch(twoFactorStep, async (step) => {
if (step !== 1) {
return
}
await nextTick()
twoFactorCodeInput.value?.focus()
})
async function verifyTwoFactorCode() {
startLoading()
try {
+4 -3
View File
@@ -1,6 +1,5 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -30,6 +29,8 @@ impl super::Validator for FabricValidator {
));
}
Ok(validate_pack_formats(archive))
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
}
}
+7 -4
View File
@@ -1,6 +1,5 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
};
use chrono::DateTime;
use std::io::Cursor;
@@ -37,7 +36,9 @@ impl super::Validator for ForgeValidator {
));
}
Ok(validate_pack_formats(archive))
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
}
}
@@ -73,6 +74,8 @@ impl super::Validator for LegacyForgeValidator {
));
};
Ok(validate_pack_formats(archive))
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
}
}
+4 -3
View File
@@ -1,6 +1,5 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -30,6 +29,8 @@ impl super::Validator for LiteLoaderValidator {
));
}
Ok(validate_pack_formats(archive))
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
}
}
+7 -8
View File
@@ -326,10 +326,9 @@ fn game_version_supported(
}
}
#[must_use]
pub fn validate_pack_formats(
archive: &mut ZipArchive<Cursor<Bytes>>,
) -> ValidationResult {
pub fn filter_out_packs(
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
) -> Result<ValidationResult, ValidationError> {
if (archive.by_name("modlist.html").is_ok()
&& archive.by_name("manifest.json").is_ok())
|| archive
@@ -339,10 +338,10 @@ pub fn validate_pack_formats(
.file_names()
.any(|x| x.starts_with("override/mods/") && x.ends_with(".jar"))
{
return ValidationResult::Warning(
"Invalid modpack file. Modpacks must be uploaded in the .mrpack format, not as a ZIP file.",
);
return Ok(ValidationResult::Warning(
"Invalid modpack file. You must upload a valid .MRPACK file.",
));
}
ValidationResult::Pass
Ok(ValidationResult::Pass)
}
+4 -3
View File
@@ -1,6 +1,5 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -34,6 +33,8 @@ impl super::Validator for NeoForgeValidator {
));
}
Ok(validate_pack_formats(archive))
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
}
}
+4 -3
View File
@@ -1,6 +1,5 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
};
use chrono::DateTime;
use std::io::Cursor;
@@ -35,6 +34,8 @@ impl super::Validator for QuiltValidator {
));
}
Ok(validate_pack_formats(archive))
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
}
}
+4 -3
View File
@@ -1,6 +1,5 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -30,6 +29,8 @@ impl super::Validator for RiftValidator {
));
}
Ok(validate_pack_formats(archive))
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
}
}
@@ -13,7 +13,8 @@ use crate::util::io::{self, IOError};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use path_util::SafeRelativeUtf8UnixPathBuf;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::iter::FromIterator;
use std::path::PathBuf;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
@@ -55,12 +56,12 @@ pub async fn export_mrpack(
let version_id = version_id.unwrap_or("1.0.0".to_string());
let mut packfile =
create_mrpack_json(&metadata, version_id, description).await?;
packfile.files.retain(|f| {
is_export_candidate_included(
f.path.as_str(),
&included_export_candidates,
)
});
let included_candidates_set = HashSet::<_>::from_iter(
included_export_candidates.iter().map(|x| x.as_str()),
);
packfile
.files
.retain(|f| included_candidates_set.contains(f.path.as_str()));
let mut path_list = Vec::new();
add_all_recursive_folder_paths(&instance_base_path, &mut path_list).await?;
@@ -79,10 +80,9 @@ pub async fn export_mrpack(
let relative_path = pack_get_relative_path(&instance_base_path, &path)?;
if packfile.files.iter().any(|f| f.path == relative_path)
|| !is_export_candidate_included(
relative_path.as_str(),
&included_export_candidates,
)
|| !included_candidates_set
.iter()
.any(|x| relative_path.starts_with(&**x))
{
continue;
}
@@ -112,18 +112,6 @@ pub async fn export_mrpack(
Ok(())
}
fn is_export_candidate_included(
path: &str,
included_export_candidates: &[String],
) -> bool {
included_export_candidates.iter().any(|candidate| {
path == candidate
|| path
.strip_prefix(candidate)
.is_some_and(|suffix| suffix.starts_with('/'))
})
}
#[tracing::instrument]
pub async fn get_pack_export_candidates(
instance_id: &str,
@@ -209,18 +209,29 @@ pub(crate) async fn list_content(
.await?;
let imported_modpack_scope = is_imported_modpack_scope(&link);
let linked_modpack_source_kind = linked_modpack_source_kind(&link);
let mut failed_modpack_identifier_lookup = false;
let modpack_ids = if imported_modpack_scope {
None
} else {
match linked_modpack_ids(&link) {
Some((_, version_id)) => {
get_cached_modpack_identifiers(
&version_id,
&state.pool,
&state.api_semaphore,
)
.await?
}
Some((_, version_id)) => match get_modpack_identifiers(
&version_id,
&resolved.content_set,
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(ids) => Some(ids),
Err(err) => {
tracing::warn!(
"Failed to fetch modpack identifiers: {}",
err
);
failed_modpack_identifier_lookup = true;
None
}
},
None => None,
}
};
@@ -232,9 +243,10 @@ pub(crate) async fn list_content(
}
} else if let Some(ids) = modpack_ids.as_ref() {
ContentFilter::ExcludeModpack(ids)
} else if let Some(source_kind) = linked_modpack_source_kind {
} else if failed_modpack_identifier_lookup {
ContentFilter::ExcludeSourceKind {
source_kind,
source_kind: linked_modpack_source_kind
.unwrap_or(ContentSourceKind::ModrinthModpack),
exclude_untracked: true,
}
} else {
@@ -1170,28 +1182,6 @@ impl ModpackIdentifiers {
}
}
async fn get_cached_modpack_identifiers(
version_id: &str,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<ModpackIdentifiers>> {
let Some(cached) =
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
.await?
else {
return Ok(None);
};
if cached.project_ids.is_empty() {
return Ok(None);
}
Ok(Some(ModpackIdentifiers {
hashes: cached.file_hashes.into_iter().collect(),
project_ids: cached.project_ids.into_iter().collect(),
}))
}
async fn get_modpack_identifiers(
version_id: &str,
content_set: &ContentSet,
-49
View File
@@ -10,55 +10,6 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-06-29T23:56:37+00:00`,
product: 'app',
version: '0.15.4',
body: `## Fixed
- Fixes another issue causing the app to freeze up when going into an instance page.`,
},
{
date: `2026-06-29T22:52:04+00:00`,
product: 'app',
version: '0.15.3',
body: `## Fixed
- Fixed hanging on larger legacy instances when visiting the Instance page.`,
},
{
date: `2026-06-29T20:44:35+00:00`,
product: 'web',
body: `## Changed
- Updated analytics graph colors to be more accessible.
- Updated project versions table UI and mobile view so columns overflow nicely.
- Updates projects page in dashboard to use new table and action bar.
- Updated translations`,
},
{
date: `2026-06-29T20:44:35+00:00`,
product: 'app',
version: '0.15.2',
body: `## Added
- Added the ["Chaos Cubed"](https://minecraft.wiki/w/Chaos_Cubed_(skin_pack)) official skin pack to the Skin selector page.
## Changed
- Redesigned the modpack export modal to align with the rest of the instance pages.
- Updated project versions table UI and mobile view so columns overflow nicely.
- Updated translations
## Fixed
- Fixed an issue where sometimes the app would desynchronise from the file system when disabling, enabling or removing mods from the Content tab.
- Fixed issue where server pinging in the Worlds tab of the instance page would be stuck in a loading state for too long.
- Fixed the Logs page in Modrinth App overflowing past the window instead of keeping the console contained.
- Fixed the Modrinth Hosting server panel in Modrinth App overflowing past the window instead of keeping the console contained.
- Fixed issue where sometimes disabling linked modpack content would not work. Thanks [@creeperkatze](@creeperkatze)!
- Fixed issue where unlinking a locally imported mrpack from an instance causes the content to never show up in the content list.`,
},
{
date: `2026-06-29T20:44:35+00:00`,
product: 'hosting',
body: `## Changed
- Updated translations`,
},
{
date: `2026-06-26T16:33:23+00:00`,
product: 'app',
-33
View File
@@ -202,39 +202,6 @@ export const coreNags: Nag[] = [
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-license',
},
},
{
id: 'add-custom-license-details',
title: defineMessage({
id: 'nags.add-license-details.title',
defaultMessage: 'Add license details',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.add-license-details.description',
defaultMessage: 'Add a valid URL and name or SPDX identifier for your custom license.',
}),
{
type: formatProjectTypeSentence(formatMessage, context.project.project_type),
},
)
},
status: 'required',
shouldShow: (context: NagContext) =>
context.project.license.id === 'LicenseRef-' ||
((!context.project.license.url || context.project.license.url === '') &&
!context.projectV3?.minecraft_server &&
context.project.license.id !== 'LicenseRef-Unknown'),
link: {
path: 'settings/license',
title: defineMessage({
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-license',
},
},
{
id: 'review-permissions',
title: defineMessage({
@@ -17,12 +17,6 @@
"nags.add-java-address.title": {
"defaultMessage": "Add a Java address"
},
"nags.add-license-details.description": {
"defaultMessage": "Add a valid URL and name or SPDX identifier for your custom license."
},
"nags.add-license-details.title": {
"defaultMessage": "Add license details"
},
"nags.add-links-server.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as a website, store, or a Discord invite."
},
@@ -1,604 +0,0 @@
<template>
<div class="flex w-full min-w-0 flex-col gap-2">
<div
class="flex w-full min-w-0 flex-col rounded-[20px] border border-solid border-surface-4 shadow-sm overflow-clip"
>
<div
class="flex h-10 w-full min-w-0 select-none flex-row items-center justify-between bg-surface-3 px-3 text-sm font-medium"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Checkbox
:model-value="allVisibleSelected"
:indeterminate="someVisibleSelected && !allVisibleSelected"
:disabled="visibleSelectableEntries.length === 0"
@update:model-value="toggleAllVisible"
/>
<button
type="button"
class="flex min-w-0 appearance-none items-center gap-1.5 border-0 bg-transparent p-0 font-semibold hover:text-primary"
:class="sortField === 'name' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('name')"
>
<span class="min-w-0 truncate">{{ formatMessage(messages.name) }}</span>
<ChevronUpIcon
v-if="sortField === 'name' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'name' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
</div>
<div class="ml-2 flex shrink-0 items-center gap-4">
<button
type="button"
class="hidden w-[92px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('size')"
>
<span>{{ formatMessage(messages.size) }}</span>
<ChevronUpIcon
v-if="sortField === 'size' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'size' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
<button
type="button"
class="hidden w-[132px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
@click="handleSort('modified')"
>
<span>{{ formatMessage(messages.modified) }}</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="size-4 shrink-0"
aria-hidden="true"
/>
</button>
<span class="size-4 shrink-0" aria-hidden="true" />
</div>
</div>
<div
v-if="!isHomePath"
role="button"
tabindex="0"
class="group flex w-full min-w-0 cursor-pointer select-none items-center gap-2 border-0 border-t border-solid border-surface-4 bg-surface-2 px-3 py-2 text-left hover:bg-surface-2.5 focus:!outline-none"
@click="navigateTo(parentPath)"
@keydown="(event) => event.key === 'Enter' && navigateTo(parentPath)"
>
<span class="size-5 shrink-0" aria-hidden="true" />
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
<UndoIcon class="size-4 group-hover:text-contrast group-focus:text-contrast" />
</div>
<span
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
>
{{ formatMessage(messages.parentFolder) }}
</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
<span class="hidden w-[132px] text-left text-sm text-secondary sm:block" />
<span class="size-4 shrink-0" aria-hidden="true" />
</div>
</div>
<div
v-if="entries.length === 0"
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-secondary"
>
<FileIcon class="size-4 shrink-0" />
<span>{{ formatMessage(messages.emptyFolderTitle) }}</span>
</div>
<div
v-for="(entry, i) in entries"
:key="`${entry.type}:${entry.path}`"
role="button"
tabindex="0"
class="group flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left first:border-t-0 focus:!outline-none"
:class="[
getRowBackgroundClass(i + (isHomePath ? 0 : 1)),
entry.disabled && entry.type === 'file'
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-surface-2.5',
]"
@click="selectEntry(entry)"
@keydown="(event) => event.key === 'Enter' && selectEntry(entry)"
>
<Checkbox
class="shrink-0"
:model-value="entry.checked"
:indeterminate="entry.indeterminate"
:disabled="entry.disabled"
:description="entry.name"
@click.stop
@update:model-value="toggleEntry(entry, $event)"
/>
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
<component
:is="entry.icon"
class="size-4 group-hover:text-contrast group-focus:text-contrast"
/>
</div>
<span
:ref="(element) => setEntryNameRef(entry.path, element)"
v-tooltip="truncatedTooltip(entryNameRefs[entry.path], entry.name)"
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
>
{{ entry.name }}
</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block">
{{ formatSize(entry) }}
</span>
<span class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block">
{{ formatModified(entry) }}
</span>
<ChevronRightIcon
class="size-4 shrink-0 text-secondary group-hover:text-contrast group-focus:text-contrast"
:class="{ invisible: entry.type !== 'directory' }"
aria-hidden="true"
/>
</div>
</div>
<div
v-for="row in fillerRowCount"
:key="`filler:${row}`"
class="flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left"
:class="getRowBackgroundClass(visibleRowCount + row - 1)"
aria-hidden="true"
>
<span class="size-5 shrink-0" />
<span class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm font-medium opacity-0">.</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] text-left text-sm sm:block" />
<span class="hidden w-[132px] text-left text-sm sm:block" />
<span class="size-4 shrink-0" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
ChevronRightIcon,
ChevronUpIcon,
FileIcon,
UndoIcon,
} from '@modrinth/assets'
import { type Component, type ComponentPublicInstance, computed, ref, watch } from 'vue'
import { useFormatBytes } from '../../composables/format-bytes'
import { useFormatDateTime } from '../../composables/format-date-time'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getDirectoryIcon, getFileIcon } from '../../utils/auto-icons'
import { truncatedTooltip } from '../../utils/truncate'
import Checkbox from './Checkbox.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
name: {
id: 'files.table-header.name',
defaultMessage: 'Name',
},
size: {
id: 'files.table-header.size',
defaultMessage: 'Size',
},
modified: {
id: 'files.table-header.modified',
defaultMessage: 'Modified',
},
itemCount: {
id: 'files.row.item-count',
defaultMessage: '{count, plural, one {# item} other {# items}}',
},
parentFolder: {
id: 'files.row.parent-folder',
defaultMessage: 'Parent folder',
},
emptyFolderTitle: {
id: 'files.layout.empty-folder-title',
defaultMessage: 'This folder is empty',
},
})
export type FileTreeSelectItem = {
path: string
type?: 'directory' | 'file'
disabled?: boolean
size?: number
modified?: number
count?: number
}
type NormalizedFileTreeSelectItem = FileTreeSelectItem & {
name: string
normalizedPath: string
}
type FileTreeSelectEntry = {
path: string
name: string
type: 'directory' | 'file'
icon: Component
checked: boolean
indeterminate: boolean
disabled: boolean
size?: number
modified?: number
count?: number
item?: NormalizedFileTreeSelectItem
}
type FileTreeSelectSortField = 'name' | 'size' | 'modified'
const formatBytes = useFormatBytes()
const formatDateTime = useFormatDateTime({
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
})
const props = withDefaults(
defineProps<{
items: FileTreeSelectItem[]
modelValue: string[]
}>(),
{
items: () => [],
modelValue: () => [],
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
(e: 'navigate', path: string): void
}>()
const currentPath = ref('')
const sortField = ref<FileTreeSelectSortField>('name')
const sortDesc = ref(false)
const entryNameRefs = ref<Record<string, HTMLElement | null>>({})
const isHomePath = computed(() => currentPath.value === '')
const parentPath = computed(() => currentPath.value.split('/').slice(0, -1).join('/'))
const initialMinimumRowCount = ref(0)
const normalizedItems = computed(() => {
const items = new Map<string, NormalizedFileTreeSelectItem>()
for (const item of props.items) {
const normalizedPath = normalizePath(item.path)
if (!normalizedPath) continue
items.set(normalizedPath, {
...item,
path: item.path,
name: getName(normalizedPath),
normalizedPath,
})
}
return [...items.values()]
})
const selectedPaths = computed(() => new Set(props.modelValue.map((path) => normalizePath(path))))
const folderPaths = computed(() => {
const paths = new Set<string>()
for (const item of normalizedItems.value) {
const segments = item.normalizedPath.split('/')
for (let i = 1; i < segments.length; i++) {
paths.add(segments.slice(0, i).join('/'))
}
if (item.type === 'directory') {
paths.add(item.normalizedPath)
}
}
return paths
})
const entries = computed<FileTreeSelectEntry[]>(() => {
const directories = new Map<string, FileTreeSelectEntry>()
const files: FileTreeSelectEntry[] = []
const currentSegments = currentPath.value ? currentPath.value.split('/') : []
for (const item of normalizedItems.value) {
const segments = item.normalizedPath.split('/')
if (!isInCurrentPath(segments, currentSegments)) continue
const remaining = segments.slice(currentSegments.length)
if (remaining.length > 1) {
const directoryName = remaining[0]
const directoryPath = [...currentSegments, directoryName].join('/')
if (!directories.has(directoryPath)) {
directories.set(directoryPath, buildDirectoryEntry(directoryPath, directoryName))
}
} else if (remaining.length === 1) {
if (item.type === 'directory') {
directories.set(
item.normalizedPath,
buildDirectoryEntry(item.normalizedPath, item.name, item),
)
} else {
files.push(buildFileEntry(item))
}
}
}
return [...directories.values(), ...files].sort(compareEntries)
})
const visibleSelectableEntries = computed(() => entries.value.filter((entry) => !entry.disabled))
const visibleSelectablePaths = computed(() => {
const paths = new Set<string>()
for (const entry of visibleSelectableEntries.value) {
for (const path of getEntrySelectablePaths(entry)) {
paths.add(path)
}
}
return [...paths]
})
const allVisibleSelected = computed(
() =>
visibleSelectablePaths.value.length > 0 &&
visibleSelectablePaths.value.every((path) => selectedPaths.value.has(path)),
)
const someVisibleSelected = computed(() =>
visibleSelectablePaths.value.some((path) => selectedPaths.value.has(path)),
)
const visibleRowCount = computed(
() => entries.value.length + (isHomePath.value ? 0 : 1) + (entries.value.length === 0 ? 1 : 0),
)
const fillerRowCount = computed(() =>
Math.max(0, initialMinimumRowCount.value - visibleRowCount.value),
)
watch(
normalizedItems,
() => {
if (currentPath.value && !folderPaths.value.has(currentPath.value)) {
currentPath.value = ''
}
},
{ immediate: true },
)
watch(
visibleRowCount,
(rowCount) => {
if (isHomePath.value && rowCount > 1 && initialMinimumRowCount.value === 0) {
initialMinimumRowCount.value = rowCount
}
},
{ immediate: true },
)
watch(
isHomePath,
() => {
if (isHomePath.value && visibleRowCount.value > 1 && initialMinimumRowCount.value === 0) {
initialMinimumRowCount.value = visibleRowCount.value
}
},
{ flush: 'post' },
)
function normalizePath(path: string) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function getName(path: string) {
return path.split('/').pop() ?? path
}
function isInCurrentPath(segments: string[], currentSegments: string[]) {
if (segments.length <= currentSegments.length) return false
return currentSegments.every((segment, index) => segments[index] === segment)
}
function handleSort(field: FileTreeSelectSortField) {
if (sortField.value === field) {
sortDesc.value = !sortDesc.value
} else {
sortField.value = field
sortDesc.value = false
}
}
function getRowBackgroundClass(index: number) {
return index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
}
function compareEntries(a: FileTreeSelectEntry, b: FileTreeSelectEntry) {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
switch (sortField.value) {
case 'modified':
return sortDesc.value
? getModifiedSortValue(a) - getModifiedSortValue(b)
: getModifiedSortValue(b) - getModifiedSortValue(a)
case 'size':
return sortDesc.value
? getSizeSortValue(a) - getSizeSortValue(b)
: getSizeSortValue(b) - getSizeSortValue(a)
default:
return sortDesc.value
? b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' })
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
}
}
function getSizeSortValue(entry: FileTreeSelectEntry) {
return entry.type === 'directory' ? (entry.count ?? 0) : (entry.size ?? 0)
}
function getModifiedSortValue(entry: FileTreeSelectEntry) {
return entry.modified ?? 0
}
function getFolderDescendants(path: string) {
return normalizedItems.value.filter((item) => item.normalizedPath.startsWith(`${path}/`))
}
function getFolderChildCount(path: string) {
const children = new Set<string>()
const prefix = `${path}/`
for (const item of normalizedItems.value) {
if (!item.normalizedPath.startsWith(prefix)) continue
const relativePath = item.normalizedPath.slice(prefix.length)
const [childName] = relativePath.split('/')
if (childName) {
children.add(childName)
}
}
return children.size
}
function getLatestModified(items: NormalizedFileTreeSelectItem[]) {
const modified = items
.map((item) => item.modified)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
if (modified.length === 0) return undefined
return Math.max(...modified)
}
function buildDirectoryEntry(
path: string,
name: string,
item?: NormalizedFileTreeSelectItem,
): FileTreeSelectEntry {
const descendants = getFolderDescendants(path).filter((item) => !item.disabled)
const selectedCount = descendants.filter((item) =>
selectedPaths.value.has(item.normalizedPath),
).length
const selected = selectedPaths.value.has(path)
return {
path,
name,
type: 'directory',
icon: getDirectoryIcon(name),
checked: selected || (descendants.length > 0 && selectedCount === descendants.length),
indeterminate: !selected && selectedCount > 0 && selectedCount < descendants.length,
disabled: item?.disabled ?? descendants.length === 0,
modified: item?.modified ?? getLatestModified(descendants),
count: item?.count ?? getFolderChildCount(path),
item,
}
}
function buildFileEntry(item: NormalizedFileTreeSelectItem): FileTreeSelectEntry {
return {
path: item.normalizedPath,
name: item.name,
type: 'file',
icon: getFileIcon(item.name),
checked: selectedPaths.value.has(item.normalizedPath),
indeterminate: false,
disabled: item.disabled ?? false,
size: item.size,
modified: item.modified,
item,
}
}
function navigateTo(path: string) {
currentPath.value = path
emit('navigate', path)
}
function setEntryNameRef(path: string, element: Element | ComponentPublicInstance | null) {
if (element instanceof HTMLElement) {
entryNameRefs.value[path] = element
} else {
entryNameRefs.value[path] = null
}
}
function selectEntry(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
navigateTo(entry.path)
} else {
toggleEntry(entry, !entry.checked)
}
}
function toggleEntry(entry: FileTreeSelectEntry, selected: boolean) {
if (entry.disabled) return
const nextSelectedPaths = new Set(selectedPaths.value)
const paths = getEntrySelectablePaths(entry)
for (const path of paths) {
if (selected) {
nextSelectedPaths.add(path)
} else {
nextSelectedPaths.delete(path)
}
}
emit('update:modelValue', [...nextSelectedPaths])
}
function getEntrySelectablePaths(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
return entry.item?.type === 'directory'
? [entry.path]
: getFolderDescendants(entry.path)
.filter((item) => !item.disabled)
.map((item) => item.normalizedPath)
}
return [entry.path]
}
function toggleAllVisible(selected: boolean) {
const nextSelectedPaths = new Set(selectedPaths.value)
for (const path of visibleSelectablePaths.value) {
if (selected) {
nextSelectedPaths.add(path)
} else {
nextSelectedPaths.delete(path)
}
}
emit('update:modelValue', [...nextSelectedPaths])
}
function formatSize(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') {
return formatMessage(messages.itemCount, { count: entry.count ?? 0 })
}
if (entry.size === undefined) return ''
return formatBytes(entry.size)
}
function formatModified(entry: FileTreeSelectEntry) {
if (entry.modified === undefined) return ''
return formatDateTime(new Date(entry.modified * 1000))
}
</script>
@@ -1,214 +0,0 @@
<template>
<div
class="flex w-fit flex-wrap gap-1.5"
:class="[wrapperClass, { 'opacity-50': disabled }]"
role="group"
aria-label="Two-factor authentication code"
@pointerdown="handlePointerDown"
>
<input
v-for="index in codeLength"
:key="index"
:ref="(element) => setCodeInput(element, index - 1)"
:value="digits[index - 1]"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
:autocomplete="index === 1 ? autocomplete : undefined"
:disabled="disabled"
:readonly="readonly"
:aria-label="`Code digit ${index}`"
class="h-12 w-11 appearance-none rounded-xl border-none bg-surface-4 p-1 text-center text-base font-medium text-primary focus:text-primary focus:ring-4 focus:ring-brand-shadow disabled:cursor-not-allowed"
:class="[inputClass, 'outline-none']"
@focus="handleFocus($event)"
@input="handleInput($event, index - 1)"
@keydown="handleKeydown($event, index - 1)"
@paste.prevent="handlePaste"
/>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref, watch, type ComponentPublicInstance } from 'vue'
const model = defineModel<string>({ default: '' })
const props = withDefaults(
defineProps<{
autocomplete?: string
disabled?: boolean
readonly?: boolean
inputClass?: string
wrapperClass?: string
}>(),
{
autocomplete: 'one-time-code',
disabled: false,
readonly: false,
},
)
const codeInputs = ref<HTMLInputElement[]>([])
const digits = ref<string[]>([])
const codeLength = 6
watch(
() => model.value,
(value) => {
const sanitizedValue = sanitizeCode(value)
if (sanitizedValue !== digits.value.join('') || digits.value.length !== codeLength) {
digits.value = Array.from({ length: codeLength }, (_, index) => sanitizedValue[index] ?? '')
}
if (value !== sanitizedValue) {
model.value = sanitizedValue
}
},
{ immediate: true },
)
function sanitizeCode(value: string) {
return value.replace(/\D/g, '').slice(0, codeLength)
}
function updateModel() {
model.value = digits.value.join('')
}
function setCodeInput(element: Element | ComponentPublicInstance | null, index: number) {
if (element instanceof HTMLInputElement) {
codeInputs.value[index] = element
}
}
function focusInput(index: number) {
const input = codeInputs.value[index]
input?.focus()
selectInputValue(input)
}
function focusFirstUnfilledCodeInput() {
const firstUnfilledIndex = digits.value.findIndex((digit) => !digit)
const inputIndex = firstUnfilledIndex === -1 ? digits.value.length - 1 : firstUnfilledIndex
focusInput(inputIndex)
}
function handlePointerDown(event: PointerEvent) {
if (props.disabled) {
return
}
if (event.target instanceof HTMLInputElement && event.target.value) {
event.preventDefault()
event.target.focus()
selectInputValue(event.target)
return
}
event.preventDefault()
focusFirstUnfilledCodeInput()
}
function handleFocus(event: FocusEvent) {
if (props.disabled) {
return
}
selectInputValue(event.target as HTMLInputElement)
}
function handleInput(event: Event, index: number) {
if (disabledOrReadonly()) {
return
}
const input = event.target as HTMLInputElement
const inputDigits = input.value.replace(/\D/g, '')
if (!inputDigits) {
digits.value[index] = ''
input.value = ''
updateModel()
return
}
if (inputDigits.length === 1) {
const digit = inputDigits.slice(-1)
digits.value[index] = digit
input.value = digit
updateModel()
if (index < codeLength - 1) {
focusInput(index + 1)
}
return
}
const pastedDigits = inputDigits.slice(0, codeLength - index)
for (const [offset, digit] of Array.from(pastedDigits).entries()) {
digits.value[index + offset] = digit
}
updateModel()
input.value = digits.value[index] ?? ''
void nextTick(focusFirstUnfilledCodeInput)
}
function handleKeydown(event: KeyboardEvent, index: number) {
if (disabledOrReadonly()) {
return
}
if (event.key === 'Backspace' && !digits.value[index] && index > 0) {
focusInput(index - 1)
} else if (event.key === 'ArrowLeft' && index > 0) {
event.preventDefault()
focusInput(index - 1)
} else if (event.key === 'ArrowRight' && index < codeLength - 1) {
event.preventDefault()
focusInput(index + 1)
}
}
function handlePaste(event: ClipboardEvent) {
if (disabledOrReadonly()) {
return
}
const clipboardText = event.clipboardData?.getData('text') ?? ''
const pastedCode = sanitizeCode(clipboardText)
if (!pastedCode) {
return
}
digits.value = Array.from({ length: codeLength }, (_, index) => pastedCode[index] ?? '')
updateModel()
void nextTick(focusFirstUnfilledCodeInput)
}
function disabledOrReadonly() {
return props.disabled || props.readonly
}
function selectInputValue(input?: HTMLInputElement) {
if (!input) {
return
}
if (input.value) {
input.select()
} else {
input.setSelectionRange(input.value.length, input.value.length)
}
}
function clear() {
digits.value = Array.from({ length: codeLength }, () => '')
updateModel()
}
defineExpose({
clear,
focus: focusFirstUnfilledCodeInput,
})
</script>
-3
View File
@@ -32,8 +32,6 @@ export { default as EmptyState } from './EmptyState.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'
export type { FileTreeSelectItem } from './FileTreeSelect.vue'
export { default as FileTreeSelect } from './FileTreeSelect.vue'
export type { FilterBarOption } from './FilterBar.vue'
export { default as FilterBar } from './FilterBar.vue'
export type { FilterPillOption } from './FilterPills.vue'
@@ -99,5 +97,4 @@ export type {
export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as TwoFactorAuthCodeInput } from './TwoFactorAuthCodeInput.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
+1 -13
View File
@@ -126,7 +126,7 @@
<div
v-if="$slots.actions"
:class="{ 'pt-4 border-0 border-t border-solid border-surface-5': actionsDivider }"
class="p-4"
class="p-4 pt-0"
>
<slot name="actions" />
</div>
@@ -292,22 +292,10 @@ function hide() {
}, 300)
}
async function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
await nextTick()
if (!scrollContainer.value) return
scrollContainer.value.scrollTo({
top: scrollContainer.value.scrollHeight,
behavior,
})
requestAnimationFrame(checkScrollState)
}
defineExpose({
show,
hide,
checkScrollState,
scrollToBottom,
})
const mouseX = ref(0)
@@ -154,19 +154,10 @@ onUnmounted(() => {
>
{{ project.title }}
</AutoLink>
<span
v-if="project.filename && (owner || version)"
class="truncate text-secondary mb-2"
>
<span v-if="project.filename" class="truncate text-secondary mb-2">
{{ project.filename }}
</span>
</div>
<div
v-if="project.filename && !(owner || version)"
class="flex min-h-8 min-w-0 items-center text-secondary"
>
<span class="truncate">{{ project.filename }}</span>
</div>
<div
v-if="owner || version"
class="flex flex-nowrap items-center gap-2 overflow-hidden text-secondary"
-3
View File
@@ -1406,9 +1406,6 @@
"files.row.item-count": {
"defaultMessage": "{count, plural, one {# item} other {# items}}"
},
"files.row.parent-folder": {
"defaultMessage": "Parent folder"
},
"files.table-header.created": {
"defaultMessage": "Created"
},
@@ -1,86 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
import FileTreeSelect, { type FileTreeSelectItem } from '../../components/base/FileTreeSelect.vue'
const modified = Math.floor(new Date('2026-06-29T10:30:00Z').getTime() / 1000)
const MODPACK_FILES: FileTreeSelectItem[] = [
{ path: 'config/fabric_loader_dependencies.json', size: 918, modified: modified - 600 },
{ path: 'config/modmenu.json', size: 2401, modified: modified - 7200 },
{ path: 'config/iris.properties', size: 1208, modified: modified - 5400 },
{ path: 'config/crash_assistant/settings.toml', size: 714, modified: modified - 1200 },
{ path: 'config/defaultoptions/options.txt', size: 4820, modified: modified - 3200 },
{ path: 'config/defaultoptions/keybindings.txt', size: 3012, modified: modified - 3300 },
{ path: 'mods/sodium-fabric-0.6.13+mc1.21.6.jar', size: 1290240, modified: modified - 900 },
{ path: 'mods/iris-fabric-1.8.8+mc1.21.6.jar', size: 2782400, modified: modified - 1100 },
{ path: 'mods/modmenu-15.0.0-beta.3.jar', size: 824220, modified: modified - 1800 },
{ path: 'resourcepacks/FreshAnimations_v1.9.3.zip', size: 4382210, modified: modified - 2600 },
{ path: 'resourcepacks/Mod Menu Helper.zip', size: 104522, modified: modified - 2800 },
{
path: 'shaderpacks/ComplementaryUnbound_r5.5.1.zip',
size: 23882210,
modified: modified - 3400,
},
{ path: 'datapacks/terralith.zip', size: 17452213, modified: modified - 4200 },
{ path: 'icon.png', size: 128044, modified: modified - 5000 },
{ path: 'profile.json', size: 928, modified: modified - 400, disabled: true },
{ path: 'modrinth_logs/launcher.log', size: 224018, modified: modified - 100, disabled: true },
]
const meta = {
title: 'Base/FileTreeSelect',
component: FileTreeSelect,
} satisfies Meta<typeof FileTreeSelect>
export default meta
export const ModpackExport: StoryObj = {
render: () => ({
components: { FileTreeSelect },
setup() {
const selected = ref([
'config/fabric_loader_dependencies.json',
'config/crash_assistant/settings.toml',
'config/defaultoptions/options.txt',
'mods/sodium-fabric-0.6.13+mc1.21.6.jar',
'mods/iris-fabric-1.8.8+mc1.21.6.jar',
'resourcepacks/FreshAnimations_v1.9.3.zip',
'shaderpacks/ComplementaryUnbound_r5.5.1.zip',
])
const selectedLabel = computed(() => `${selected.value.length} selected`)
return {
items: MODPACK_FILES,
selected,
selectedLabel,
}
},
template: /*html*/ `
<div class="max-w-2xl">
<FileTreeSelect v-model="selected" :items="items" />
<div class="mt-4 rounded-[20px] bg-surface-3 p-4 text-sm text-secondary">
<div class="font-semibold text-contrast">{{ selectedLabel }}</div>
<div class="mt-2 flex flex-col gap-1">
<span v-for="path in selected" :key="path" class="truncate">{{ path }}</span>
</div>
</div>
</div>
`,
}),
}
export const EmptyRoot: StoryObj = {
render: () => ({
components: { FileTreeSelect },
setup() {
const selected = ref<string[]>([])
return { selected }
},
template: /*html*/ `
<div class="max-w-2xl">
<FileTreeSelect v-model="selected" :items="[]" />
</div>
`,
}),
}
@@ -1,51 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import TwoFactorAuthCodeInput from '../../components/base/TwoFactorAuthCodeInput.vue'
const meta = {
title: 'Base/TwoFactorAuthCodeInput',
component: TwoFactorAuthCodeInput,
} satisfies Meta<typeof TwoFactorAuthCodeInput>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { TwoFactorAuthCodeInput },
setup() {
const code = ref('')
return { code }
},
template: `
<TwoFactorAuthCodeInput v-model="code" />
`,
}),
}
export const Filled: Story = {
render: () => ({
components: { TwoFactorAuthCodeInput },
setup() {
const code = ref('123456')
return { code }
},
template: `
<TwoFactorAuthCodeInput v-model="code" />
`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { TwoFactorAuthCodeInput },
setup() {
const code = ref('123456')
return { code }
},
template: `
<TwoFactorAuthCodeInput v-model="code" disabled />
`,
}),
}