mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91e0af4d84 | ||
|
|
26e98b7155 | ||
|
|
77b06f679e | ||
|
|
50e9472dd9 | ||
|
|
b569354793 | ||
|
|
b667b3ae9e | ||
|
|
16de6182e4 | ||
|
|
364fed52dd | ||
|
|
0fe695721a | ||
|
|
692f22b749 | ||
|
|
ed7792097d | ||
|
|
c56ce6c9ee | ||
|
|
91bc58b8ee | ||
|
|
38d757b66a | ||
|
|
862b54f00d | ||
|
|
c2f4835fe7 | ||
|
|
b97c191d75 |
@@ -76,13 +76,6 @@ 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' }}
|
||||
|
||||
@@ -78,13 +78,6 @@ 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
|
||||
|
||||
@@ -81,13 +81,6 @@ 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,28 +1,32 @@
|
||||
<script setup>
|
||||
import { WrenchIcon, XIcon } from '@modrinth/assets'
|
||||
import { 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, VersionIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
|
||||
import { PackageIcon } from '@/assets/icons'
|
||||
import {
|
||||
export_instance_mrpack,
|
||||
get_full_path,
|
||||
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',
|
||||
@@ -39,15 +43,7 @@ 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({
|
||||
@@ -59,8 +55,9 @@ const props = defineProps({
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
resetExportState()
|
||||
exportModal.value.show()
|
||||
initFiles()
|
||||
void initFiles().catch(handleError)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -69,62 +66,33 @@ const nameInput = ref(props.instance.name)
|
||||
const exportDescription = ref('')
|
||||
const versionInput = ref('1.0.0')
|
||||
const files = ref([])
|
||||
const folders = ref([])
|
||||
const selectedFilePaths = ref([])
|
||||
const fileTreeKey = ref(0)
|
||||
const filesLoadId = ref(0)
|
||||
const instanceRoot = ref('')
|
||||
const loadedDirectories = ref(new Set())
|
||||
|
||||
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)
|
||||
}
|
||||
}),
|
||||
)
|
||||
folders.value = [...newFolders.entries()].map(([name, value]) => [
|
||||
{
|
||||
name,
|
||||
showingMore: false,
|
||||
},
|
||||
value,
|
||||
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)),
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
files.value = exportCandidates
|
||||
selectedFilePaths.value = files.value
|
||||
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
|
||||
.map((file) => file.path)
|
||||
}
|
||||
|
||||
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: [
|
||||
@@ -139,7 +107,7 @@ const exportPack = async () => {
|
||||
export_instance_mrpack(
|
||||
props.instance.id,
|
||||
outputPath,
|
||||
filesToExport,
|
||||
selectedFilePaths.value,
|
||||
versionInput.value,
|
||||
exportDescription.value,
|
||||
nameInput.value,
|
||||
@@ -147,94 +115,176 @@ 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>
|
||||
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
|
||||
<div class="flex flex-col gap-4 w-[40rem]">
|
||||
<NewModal
|
||||
ref="exportModal"
|
||||
:header="formatMessage(messages.header)"
|
||||
scrollable
|
||||
width="46rem"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">{{ 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">
|
||||
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">
|
||||
{{ 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">
|
||||
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="m-0 text-contrast font-semibold">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<FileTreeSelect
|
||||
:key="fileTreeKey"
|
||||
v-model="selectedFilePaths"
|
||||
class="min-w-0"
|
||||
:items="files"
|
||||
@navigate="loadExportDirectory"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="exportModal.hide">
|
||||
@@ -249,6 +299,6 @@ const exportPack = async () => {
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
@@ -301,11 +301,12 @@ const messages = defineMessages({
|
||||
</ButtonStyled>
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchFriends)"
|
||||
clearable
|
||||
variant="outlined"
|
||||
wrapper-class="flex-1"
|
||||
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"
|
||||
@keyup.esc="search = ''"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -122,9 +122,6 @@
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "إسم حزمة التعديل"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "أختيار الملفات التي سيتم تصديرها"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "رقم الإصدار"
|
||||
},
|
||||
|
||||
@@ -203,18 +203,12 @@
|
||||
"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,18 +164,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +182,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +164,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +176,12 @@
|
||||
"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,18 +170,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +164,12 @@
|
||||
"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,18 +155,12 @@
|
||||
"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,18 +170,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +170,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +170,12 @@
|
||||
"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,18 +170,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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,18 +203,12 @@
|
||||
"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?.path)
|
||||
const fullPath = await get_full_path(instance.value.id)
|
||||
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 !== 'pack_installing' &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
!isBulkOperating.value
|
||||
) {
|
||||
await initProjects()
|
||||
|
||||
@@ -414,7 +414,7 @@ components:
|
||||
- unknown
|
||||
- signature
|
||||
example: required-resource-pack
|
||||
# https://github.com/modrinth/labrinth/blob/master/src/routes/version_creation.rs#L27-L57
|
||||
# https://github.com/modrinth/code/blob/main/apps/labrinth/src/routes/v2/version_creation.rs#L32-L76
|
||||
CreatableVersion:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseVersion'
|
||||
@@ -446,6 +446,11 @@ 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
|
||||
|
||||
+30
-30
@@ -41,38 +41,38 @@ export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
|
||||
|
||||
export const LIGHT_LEGEND_PALETTE = [
|
||||
'hsl(152, 100%, 34%)',
|
||||
'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%)',
|
||||
'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%)',
|
||||
]
|
||||
|
||||
export const DARK_LEGEND_PALETTE = [
|
||||
'hsl(145, 78%, 48%)',
|
||||
'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%)',
|
||||
'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%)',
|
||||
]
|
||||
|
||||
+44
-13
@@ -288,6 +288,7 @@ type PaletteRankEntry = {
|
||||
key: string
|
||||
label: string
|
||||
total: number
|
||||
excludedFromRank?: boolean
|
||||
}
|
||||
|
||||
function formatDatasetTooltip(projectName: string | undefined): string | undefined {
|
||||
@@ -330,9 +331,12 @@ function buildPaletteColorsByDownloadRank(
|
||||
const colorsByKey = new Map<string, string>()
|
||||
if (palette.length === 0) return colorsByKey
|
||||
|
||||
const sortedEntries = [...entries].sort(
|
||||
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
|
||||
)
|
||||
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]
|
||||
|
||||
sortedEntries.forEach((entry, index) => {
|
||||
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
|
||||
})
|
||||
@@ -340,6 +344,27 @@ 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,
|
||||
@@ -499,11 +524,14 @@ export function buildChartDatasets(
|
||||
})
|
||||
|
||||
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
|
||||
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
|
||||
key: breakdownKey,
|
||||
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
|
||||
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
|
||||
})),
|
||||
Array.from(dataByBreakdown.keys()).map((breakdownKey) =>
|
||||
buildPaletteRankEntry(
|
||||
breakdownKey,
|
||||
breakdownValuesByKey.get(breakdownKey) ?? [],
|
||||
downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
|
||||
formatChartBreakdownLabels,
|
||||
),
|
||||
),
|
||||
palette,
|
||||
)
|
||||
|
||||
@@ -675,11 +703,14 @@ export function buildChartDatasets(
|
||||
})
|
||||
|
||||
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
|
||||
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
|
||||
key: breakdownKey,
|
||||
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
|
||||
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
|
||||
})),
|
||||
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) =>
|
||||
buildPaletteRankEntry(
|
||||
breakdownKey,
|
||||
breakdownValuesByKey.get(breakdownKey) ?? [],
|
||||
downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
|
||||
formatChartBreakdownLabels,
|
||||
),
|
||||
),
|
||||
palette,
|
||||
)
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
:options="[userOption, ...ownerOptions]"
|
||||
searchable
|
||||
:disabled="hasHitLimit"
|
||||
select-search-text-on-focus
|
||||
show-icon-in-selected
|
||||
/>
|
||||
<span>{{ formatMessage(messages.ownerDescription) }}</span>
|
||||
@@ -129,7 +130,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { OrganizationIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
@@ -362,7 +363,13 @@ async function fetchOrganizations() {
|
||||
}),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
: markRaw(
|
||||
defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () => h(OrganizationIcon, { class: 'size-5' }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}))
|
||||
if (props.organizationId) owner.value = props.organizationId
|
||||
} catch (err) {
|
||||
@@ -395,6 +402,7 @@ async function createProject() {
|
||||
server_side: 'required',
|
||||
license_id: 'LicenseRef-Unknown',
|
||||
is_draft: true,
|
||||
organization_id: owner.value !== 'self' ? owner.value : undefined,
|
||||
}
|
||||
|
||||
formData.append('data', JSON.stringify(projectData))
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
INSERT INTO notifications_types
|
||||
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
|
||||
VALUES ('shared_instance_invite', 1, FALSE, TRUE);
|
||||
|
||||
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
|
||||
VALUES (NULL, 'email', 'shared_instance_invite', FALSE);
|
||||
@@ -73,6 +73,10 @@ pub enum LegacyNotificationBody {
|
||||
invited_by: UserId,
|
||||
role: String,
|
||||
},
|
||||
SharedInstanceInvite {
|
||||
shared_instance_id: String,
|
||||
shared_instance_name: String,
|
||||
},
|
||||
StatusChange {
|
||||
project_id: ProjectId,
|
||||
old_status: ProjectStatus,
|
||||
@@ -177,6 +181,9 @@ impl LegacyNotification {
|
||||
NotificationBody::ServerInvite { .. } => {
|
||||
Some("server_invite".to_string())
|
||||
}
|
||||
NotificationBody::SharedInstanceInvite { .. } => {
|
||||
Some("shared_instance_invite".to_string())
|
||||
}
|
||||
NotificationBody::StatusChange { .. } => {
|
||||
Some("status_change".to_string())
|
||||
}
|
||||
@@ -294,6 +301,13 @@ impl LegacyNotification {
|
||||
invited_by,
|
||||
role,
|
||||
},
|
||||
NotificationBody::SharedInstanceInvite {
|
||||
shared_instance_id,
|
||||
shared_instance_name,
|
||||
} => LegacyNotificationBody::SharedInstanceInvite {
|
||||
shared_instance_id,
|
||||
shared_instance_name,
|
||||
},
|
||||
NotificationBody::StatusChange {
|
||||
project_id,
|
||||
old_status,
|
||||
|
||||
@@ -36,6 +36,7 @@ pub enum NotificationType {
|
||||
TeamInvite,
|
||||
OrganizationInvite,
|
||||
ServerInvite,
|
||||
SharedInstanceInvite,
|
||||
StatusChange,
|
||||
ModeratorMessage,
|
||||
LegacyMarkdown,
|
||||
@@ -71,6 +72,7 @@ impl NotificationType {
|
||||
NotificationType::TeamInvite => "team_invite",
|
||||
NotificationType::OrganizationInvite => "organization_invite",
|
||||
NotificationType::ServerInvite => "server_invite",
|
||||
NotificationType::SharedInstanceInvite => "shared_instance_invite",
|
||||
NotificationType::StatusChange => "status_change",
|
||||
NotificationType::ModeratorMessage => "moderator_message",
|
||||
NotificationType::LegacyMarkdown => "legacy_markdown",
|
||||
@@ -112,6 +114,7 @@ impl NotificationType {
|
||||
"team_invite" => NotificationType::TeamInvite,
|
||||
"organization_invite" => NotificationType::OrganizationInvite,
|
||||
"server_invite" => NotificationType::ServerInvite,
|
||||
"shared_instance_invite" => NotificationType::SharedInstanceInvite,
|
||||
"status_change" => NotificationType::StatusChange,
|
||||
"moderator_message" => NotificationType::ModeratorMessage,
|
||||
"legacy_markdown" => NotificationType::LegacyMarkdown,
|
||||
@@ -173,6 +176,10 @@ pub enum NotificationBody {
|
||||
invited_by: UserId,
|
||||
role: String,
|
||||
},
|
||||
SharedInstanceInvite {
|
||||
shared_instance_id: String,
|
||||
shared_instance_name: String,
|
||||
},
|
||||
StatusChange {
|
||||
project_id: ProjectId,
|
||||
old_status: ProjectStatus,
|
||||
@@ -288,6 +295,9 @@ impl NotificationBody {
|
||||
NotificationBody::ServerInvite { .. } => {
|
||||
NotificationType::ServerInvite
|
||||
}
|
||||
NotificationBody::SharedInstanceInvite { .. } => {
|
||||
NotificationType::SharedInstanceInvite
|
||||
}
|
||||
NotificationBody::StatusChange { .. } => {
|
||||
NotificationType::StatusChange
|
||||
}
|
||||
@@ -470,6 +480,32 @@ impl From<DBNotification> for Notification {
|
||||
},
|
||||
],
|
||||
),
|
||||
NotificationBody::SharedInstanceInvite {
|
||||
shared_instance_name,
|
||||
..
|
||||
} => (
|
||||
"You have been invited to a shared instance!".to_string(),
|
||||
format!(
|
||||
"An invite has been sent for you to join {shared_instance_name}"
|
||||
),
|
||||
"#".to_string(),
|
||||
vec![
|
||||
NotificationAction {
|
||||
name: "Accept".to_string(),
|
||||
action_route: (
|
||||
"POST".to_string(),
|
||||
String::new(),
|
||||
),
|
||||
},
|
||||
NotificationAction {
|
||||
name: "Deny".to_string(),
|
||||
action_route: (
|
||||
"POST".to_string(),
|
||||
String::new(),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
NotificationBody::StatusChange {
|
||||
old_status,
|
||||
new_status,
|
||||
|
||||
@@ -772,6 +772,7 @@ async fn collect_template_variables(
|
||||
}
|
||||
|
||||
NotificationBody::ProjectUpdate { .. }
|
||||
| NotificationBody::SharedInstanceInvite { .. }
|
||||
| NotificationBody::ModeratorMessage { .. }
|
||||
| NotificationBody::LegacyMarkdown { .. }
|
||||
| NotificationBody::Unknown => Ok(EmailTemplate::Static(map)),
|
||||
|
||||
@@ -8,7 +8,7 @@ use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashSet;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -388,7 +388,7 @@ pub async fn pride_26(
|
||||
Ok(campaign_info) => Ok(web::Json(campaign_info)),
|
||||
Err(error) => {
|
||||
if let Some(cached) = cached {
|
||||
warn!(
|
||||
debug!(
|
||||
"Failed to refresh campaign info from Tiltify: {error:?}"
|
||||
);
|
||||
Ok(web::Json(cached))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
@@ -29,8 +30,6 @@ impl super::Validator for FabricValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use std::io::Cursor;
|
||||
@@ -36,9 +37,7 @@ impl super::Validator for ForgeValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +73,6 @@ impl super::Validator for LegacyForgeValidator {
|
||||
));
|
||||
};
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
@@ -29,8 +30,6 @@ impl super::Validator for LiteLoaderValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,9 +326,10 @@ fn game_version_supported(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_out_packs(
|
||||
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
|
||||
) -> Result<ValidationResult, ValidationError> {
|
||||
#[must_use]
|
||||
pub fn validate_pack_formats(
|
||||
archive: &mut ZipArchive<Cursor<Bytes>>,
|
||||
) -> ValidationResult {
|
||||
if (archive.by_name("modlist.html").is_ok()
|
||||
&& archive.by_name("manifest.json").is_ok())
|
||||
|| archive
|
||||
@@ -338,10 +339,10 @@ pub fn filter_out_packs(
|
||||
.file_names()
|
||||
.any(|x| x.starts_with("override/mods/") && x.ends_with(".jar"))
|
||||
{
|
||||
return Ok(ValidationResult::Warning(
|
||||
"Invalid modpack file. You must upload a valid .MRPACK file.",
|
||||
));
|
||||
return ValidationResult::Warning(
|
||||
"Invalid modpack file. Modpacks must be uploaded in the .mrpack format, not as a ZIP file.",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
ValidationResult::Pass
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
@@ -33,8 +34,6 @@ impl super::Validator for NeoForgeValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use std::io::Cursor;
|
||||
@@ -34,8 +35,6 @@ impl super::Validator for QuiltValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::validate::{
|
||||
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
|
||||
SupportedGameVersions, ValidationError, ValidationResult,
|
||||
validate_pack_formats,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
@@ -29,8 +30,6 @@ impl super::Validator for RiftValidator {
|
||||
));
|
||||
}
|
||||
|
||||
filter_out_packs(archive)?;
|
||||
|
||||
Ok(ValidationResult::Pass)
|
||||
Ok(validate_pack_formats(archive))
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\t\t\tUPDATE instance_content_entries\n\t\t\t\tSET file_id = ?, modified_at = ?\n\t\t\t\tWHERE instance_id = ? AND file_id = ?\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "028eb6e4c6e87388ea7a371bd7b41e2b10c828ab93140f6d9091fbc76e03cdec"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\t\t\t\t\t\t\t\t\t\t\t\tSELECT id\n\t\t\t\t\t\t\t\t\t\t\t\t\tFROM instances\n\t\t\t\t\t\t\t\t\t\t\t\t\tWHERE path = ?\n\t\t\t\t\t\t\t\t\t\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5363aa346fdead6a331a5a209d0505315b9cbb5b842216db5585cc2f7f739180"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\t\t\tDELETE FROM instance_files\n\t\t\t\tWHERE id = ?\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7a912eb3fd9b7da37cce1d8153241aba899e679a6248c8a1de21b7b65155843e"
|
||||
}
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tSELECT id\n\t\tFROM instance_files\n\t\tWHERE instance_id = ? AND relative_path = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b4885df7ebd5300533179c0ed98cbca8ca982489597f413638c6af8ccbbe2bfa"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\t\t\tDELETE FROM instance_content_entries\n\t\t\t\tWHERE id IN (\n\t\t\t\t\tSELECT target_entry.id\n\t\t\t\t\tFROM instance_content_entries target_entry\n\t\t\t\t\tWHERE target_entry.instance_id = ?\n\t\t\t\t\t\tAND target_entry.file_id = ?\n\t\t\t\t\t\tAND EXISTS (\n\t\t\t\t\t\t\tSELECT 1\n\t\t\t\t\t\t\tFROM instance_content_entries source_entry\n\t\t\t\t\t\t\tWHERE source_entry.instance_id = target_entry.instance_id\n\t\t\t\t\t\t\t\tAND source_entry.content_set_id = target_entry.content_set_id\n\t\t\t\t\t\t\t\tAND source_entry.file_id = ?\n\t\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "edab0097706c394608dbc8c65670159a6d568cb4d408876d874d218d83bc8caf"
|
||||
}
|
||||
@@ -13,8 +13,7 @@ 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, HashSet};
|
||||
use std::iter::FromIterator;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -56,12 +55,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?;
|
||||
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()));
|
||||
packfile.files.retain(|f| {
|
||||
is_export_candidate_included(
|
||||
f.path.as_str(),
|
||||
&included_export_candidates,
|
||||
)
|
||||
});
|
||||
|
||||
let mut path_list = Vec::new();
|
||||
add_all_recursive_folder_paths(&instance_base_path, &mut path_list).await?;
|
||||
@@ -80,9 +79,10 @@ 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)
|
||||
|| !included_candidates_set
|
||||
.iter()
|
||||
.any(|x| relative_path.starts_with(&**x))
|
||||
|| !is_export_candidate_included(
|
||||
relative_path.as_str(),
|
||||
&included_export_candidates,
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -112,6 +112,18 @@ 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,
|
||||
|
||||
@@ -619,6 +619,83 @@ pub(crate) async fn rename_instance_file(
|
||||
) -> crate::Result<Option<InstanceFile>> {
|
||||
let enabled = i64::from(enabled);
|
||||
let modified_at = Utc::now().timestamp();
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let source_id = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT id
|
||||
FROM instance_files
|
||||
WHERE instance_id = ? AND relative_path = ?
|
||||
",
|
||||
instance_id,
|
||||
old_relative_path,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let target_id = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT id
|
||||
FROM instance_files
|
||||
WHERE instance_id = ? AND relative_path = ?
|
||||
",
|
||||
instance_id,
|
||||
new_relative_path,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let (Some(source_id), Some(target_id)) =
|
||||
(source_id.as_deref(), target_id.as_deref())
|
||||
&& source_id != target_id
|
||||
{
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_content_entries
|
||||
WHERE id IN (
|
||||
SELECT target_entry.id
|
||||
FROM instance_content_entries target_entry
|
||||
WHERE target_entry.instance_id = ?
|
||||
AND target_entry.file_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM instance_content_entries source_entry
|
||||
WHERE source_entry.instance_id = target_entry.instance_id
|
||||
AND source_entry.content_set_id = target_entry.content_set_id
|
||||
AND source_entry.file_id = ?
|
||||
)
|
||||
)
|
||||
",
|
||||
instance_id,
|
||||
target_id,
|
||||
source_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_content_entries
|
||||
SET file_id = ?, modified_at = ?
|
||||
WHERE instance_id = ? AND file_id = ?
|
||||
",
|
||||
source_id,
|
||||
modified_at,
|
||||
instance_id,
|
||||
target_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_files
|
||||
WHERE id = ?
|
||||
",
|
||||
target_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -638,9 +715,11 @@ pub(crate) async fn rename_instance_file(
|
||||
instance_id,
|
||||
old_relative_path,
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
get_instance_file_by_relative_path(instance_id, new_relative_path, pool)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -209,29 +209,18 @@ 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)) => 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
|
||||
}
|
||||
},
|
||||
Some((_, version_id)) => {
|
||||
get_cached_modpack_identifiers(
|
||||
&version_id,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
@@ -243,10 +232,9 @@ pub(crate) async fn list_content(
|
||||
}
|
||||
} else if let Some(ids) = modpack_ids.as_ref() {
|
||||
ContentFilter::ExcludeModpack(ids)
|
||||
} else if failed_modpack_identifier_lookup {
|
||||
} else if let Some(source_kind) = linked_modpack_source_kind {
|
||||
ContentFilter::ExcludeSourceKind {
|
||||
source_kind: linked_modpack_source_kind
|
||||
.unwrap_or(ContentSourceKind::ModrinthModpack),
|
||||
source_kind,
|
||||
exclude_untracked: true,
|
||||
}
|
||||
} else {
|
||||
@@ -1182,6 +1170,28 @@ 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,
|
||||
|
||||
@@ -10,6 +10,55 @@ 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',
|
||||
|
||||
@@ -202,6 +202,39 @@ 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,6 +17,12 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
<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>
|
||||
@@ -32,6 +32,8 @@ 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'
|
||||
|
||||
@@ -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 pt-0"
|
||||
class="p-4"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -292,10 +292,22 @@ 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,10 +154,19 @@ onUnmounted(() => {
|
||||
>
|
||||
{{ project.title }}
|
||||
</AutoLink>
|
||||
<span v-if="project.filename" class="truncate text-secondary mb-2">
|
||||
<span
|
||||
v-if="project.filename && (owner || version)"
|
||||
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"
|
||||
|
||||
@@ -1406,6 +1406,9 @@
|
||||
"files.row.item-count": {
|
||||
"defaultMessage": "{count, plural, one {# item} other {# items}}"
|
||||
},
|
||||
"files.row.parent-folder": {
|
||||
"defaultMessage": "Parent folder"
|
||||
},
|
||||
"files.table-header.created": {
|
||||
"defaultMessage": "Created"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user