mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b10dd7bb | ||
|
|
5bebfdda9f | ||
|
|
de15be3635 | ||
|
|
8660437fd3 | ||
|
|
6cc5ea3de6 |
@@ -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,6 +55,7 @@ const props = defineProps({
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
resetExportState()
|
||||
exportModal.value.show()
|
||||
initFiles()
|
||||
},
|
||||
@@ -69,62 +66,38 @@ 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 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,
|
||||
const loadId = ++filesLoadId.value
|
||||
const [filePaths, instanceRoot] = await Promise.all([
|
||||
get_pack_export_candidates(props.instance.id),
|
||||
get_full_path(props.instance.id),
|
||||
])
|
||||
const expandedFiles = await Promise.all(
|
||||
filePaths.map((path) => expandExportCandidate(instanceRoot, path)),
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
files.value = expandedFiles.flat()
|
||||
selectedFilePaths.value = files.value
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.disabled &&
|
||||
(file.path.startsWith('mods') ||
|
||||
file.path.startsWith('datapacks') ||
|
||||
file.path.startsWith('resourcepacks') ||
|
||||
file.path.startsWith('shaderpacks') ||
|
||||
file.path.startsWith('config')),
|
||||
)
|
||||
.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 +112,7 @@ const exportPack = async () => {
|
||||
export_instance_mrpack(
|
||||
props.instance.id,
|
||||
outputPath,
|
||||
filesToExport,
|
||||
selectedFilePaths.value,
|
||||
versionInput.value,
|
||||
exportDescription.value,
|
||||
nameInput.value,
|
||||
@@ -147,94 +120,140 @@ 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
|
||||
}
|
||||
|
||||
async function expandExportCandidate(instanceRoot, path) {
|
||||
try {
|
||||
const entries = await readDir(`${instanceRoot}/${path}`)
|
||||
if (entries.length === 0) {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return [
|
||||
{
|
||||
path,
|
||||
type: 'directory',
|
||||
disabled: true,
|
||||
modified: metadata.modified,
|
||||
count: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const children = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const childPath = `${path}/${entry.name}`
|
||||
if (entry.isDirectory) {
|
||||
return expandExportCandidate(instanceRoot, childPath)
|
||||
}
|
||||
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, childPath)
|
||||
return [
|
||||
{
|
||||
path: childPath,
|
||||
type: 'file',
|
||||
disabled: isExportCandidateDisabled(childPath),
|
||||
size: metadata.size,
|
||||
modified: metadata.modified,
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
return children.flat()
|
||||
} catch {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return [
|
||||
{
|
||||
path,
|
||||
type: 'file',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
size: metadata.size,
|
||||
modified: metadata.modified,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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 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"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="exportModal.hide">
|
||||
@@ -249,6 +268,6 @@ const exportPack = async () => {
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
</NewModal>
|
||||
</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": "版本號碼"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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