Compare commits

...
Author SHA1 Message Date
tdgao c6c671c668 feat: update account settings 2fa set up to use 2fa code component 2026-06-30 11:13:55 -06:00
tdgao dc569ecd89 feat: improve 2fa UX 2026-06-30 10:58:29 -06:00
tdgao 7bfb418a11 Merge branch 'main' into truman/reusable-2fa-code-component 2026-06-30 10:33:07 -06:00
ThatGravyBoatandGitHub b569354793 fix: packs not being validated correctly (#6533) 2026-06-30 14:02:58 +00:00
Michael H. b667b3ae9e fix: remove sccache s3 2026-06-30 11:41:08 +02:00
coolbotandGitHub 16de6182e4 Add nag for custom license info. (#6564)
* add nag for custom license info

* preppr
2026-06-30 02:25:13 +00:00
Calum H.andGitHub 364fed52dd feat: changelog (#6563) 2026-06-30 01:57:11 +02:00
Calum H.andGitHub 0fe695721a fix: try fix app freezing on instance page load (#6562)
* fix: app freezing fix

* fix: fmt
2026-06-30 01:55:55 +02:00
Calum H.andGitHub 692f22b749 feat: changelog 0.15.3 (#6560)
feat: changlog 0.15.3
2026-06-30 00:54:16 +02:00
Calum H.andGitHub ed7792097d fix: better handling of larger instances for ExportModal (#6559) 2026-06-30 00:51:25 +02:00
Prospector c56ce6c9ee changelog 2026-06-29 13:45:04 -07:00
Calum H.andGitHub 91bc58b8ee fix: cleanup stale db entry on rename instance file (#6549)
fix: race on renaming instance file
2026-06-29 17:36:46 +00:00
Truman GaoandGitHub 38d757b66a fix: analytics colour pick is not excluding unknown (#6552)
* fix: analytics colour picking not excluding unknown

* feat: improve colours, better accessibility
2026-06-29 17:17:03 +00:00
Calum H.andGitHub 862b54f00d refactor: instance export modal (#6543)
* refactor: export modal w the design system

* fix: lint

* fix: rev

* fix: lint

* fix: qa
2026-06-29 17:01:00 +00:00
Calum H.andGitHub c2f4835fe7 fix: spacing + friends (#6550) 2026-06-29 17:00:59 +00:00
ThatGravyBoatandGitHub b97c191d75 fix(docs): create version endpoint missing file_types field (#6551) 2026-06-29 16:22:08 +00:00
Truman GaoandGitHub b0c6a06909 feat: update versions table (#6502)
* feat: add new versions table, move environments column to be feature flag toggled, and add header/cell class props to Table.vue

* feat: implement much better table column widths

* feat: update version filter control to use multiselect

* feat: implement clicking table row to go to version

* feat: mobile pass on versions table

* feat: use multiselect for platform, and fix game version show all versions when dont exist

* pnpm prepr

* feat: replace dashboard projects table with new table

* feat: update projects so its out of card

* fix: version actions buttons column width

* feat: implement overflow on loaders cell

* update platform and version width

* feat: update project icon

* feat: change edit links to use floating action bar

* pnpm prepr
2026-06-29 15:01:07 +00:00
ThatGravyBoatandGitHub fc3e384f54 fix: tech review card not refreshing on status change (#6508)
fix: tech rev card not refreshing on status change
2026-06-29 14:40:20 +00:00
Calum H.andGitHub 87ff8fd4d9 fix: server panel not using correct endpoint for mrpack reupload (#6539)
fix: server panel not using correct endpoint for local modpack reupload
2026-06-29 14:39:03 +00:00
Calum H.andGitHub 288c1fb970 fix: reduce ping timeout in app to 5 sec not 30 sec (#6545)
* fix: reduce ping timeout in app to 5 sec not 30 sec

* feat: readd comment
2026-06-29 14:38:51 +00:00
Calum H.andGitHub f6e6ac28d4 fix: change fs watcher to rely more on instance ids than path (#6547)
* fix: change fs watcher to rely more on instance ids than path

* fix: fmt
2026-06-29 14:38:44 +00:00
Calum H.andGitHub 4a972bca73 fix: change logic when selecting filters in Worlds.vue (#6548) 2026-06-29 14:38:37 +00:00
Calum H.andGitHub b09c26f616 fix: console sizing issues (#6540)
fix: console sizing issues (for good)
2026-06-29 14:38:30 +00:00
ArthurandGitHub 204bd52a5a fix: modpack content modal toggle state (#6532)
Fix modpack content modal toggle state
2026-06-29 14:04:55 +00:00
Calum H.andGitHub b59f327b04 feat: add chaos cubed skins (#6463) 2026-06-29 12:18:15 +00:00
tdgao 129dfa66ca feat: implement 2fa code input component 2026-06-15 22:27:40 -07:00
123 changed files with 3187 additions and 1824 deletions
-7
View File
@@ -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' }}
-7
View File
@@ -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
-7
View File
@@ -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>
+30 -23
View File
@@ -404,28 +404,35 @@ export async function refreshServerData(
): Promise<void> {
const refreshTime = Date.now()
serverData.refreshing = true
await get_server_status(address, protocolVersion)
.then((status) => {
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
}
})
.finally(() => {
serverData.refreshing = false
})
.catch((err) => {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
refreshServerData(serverData, { version: 74, legacy: true }, address)
}
})
try {
const status = await get_server_status(address, protocolVersion)
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
} else {
delete serverData.rawMotd
delete serverData.renderedMotd
}
} catch (err) {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
await refreshServerData(serverData, { version: 74, legacy: true }, address)
return
}
if (!serverData.lastSuccessfulRefresh || serverData.lastSuccessfulRefresh <= refreshTime) {
delete serverData.status
delete serverData.rawMotd
delete serverData.renderedMotd
}
} finally {
serverData.refreshing = false
}
}
export function refreshServers(
@@ -444,7 +451,7 @@ export function refreshServers(
}
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
// noinspection ES6MissingAwait - handled by refreshServerData
Object.keys(serverData).forEach((address) =>
refreshServerData(serverData[address], protocolVersion, address),
)
@@ -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"
},
@@ -542,6 +536,9 @@
"app.skins.section.builders-and-biomes": {
"message": "Builders & Biomes"
},
"app.skins.section.chaos-cubed": {
"message": "Chaos Cubed"
},
"app.skins.section.chase-the-skies": {
"message": "Chase the Skies"
},
@@ -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": "版本號碼"
},
+6
View File
@@ -114,6 +114,10 @@ const messages = defineMessages({
id: 'app.skins.section.tiny-takeover',
defaultMessage: 'Tiny Takeover',
},
chaosCubedSection: {
id: 'app.skins.section.chaos-cubed',
defaultMessage: 'Chaos Cubed',
},
rateLimitTitle: {
id: 'app.skins.rate-limit.title',
defaultMessage: 'Slow down!',
@@ -447,6 +451,8 @@ function getDefaultSkinSectionTitle(section?: string) {
return formatMessage(messages.mountsOfMayhemSection)
case 'Tiny Takeover':
return formatMessage(messages.tinyTakeoverSection)
case 'Chaos Cubed':
return formatMessage(messages.chaosCubedSection)
case 'Default skins':
return formatMessage(messages.defaultSkinsSection)
default:
@@ -1,7 +1,11 @@
<template>
<div class="h-full w-full pt-6">
<div
class="h-full w-full pt-6"
:class="isContainedServerRoute ? 'box-border min-h-0 overflow-hidden' : ''"
>
<ServersManageRootLayout
:server-id="serverId"
:layout-mode="isContainedServerRoute ? 'contained' : 'page'"
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
@@ -63,6 +67,8 @@ const queryClient = useQueryClient()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
const isContainedServerRoute = computed(() => route.name === 'ServerManageOverview')
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
@@ -3,5 +3,5 @@ import { ServersManageOverviewPage } from '@modrinth/ui'
</script>
<template>
<ServersManageOverviewPage />
<ServersManageOverviewPage contained-console />
</template>
@@ -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()
@@ -618,10 +618,7 @@ const filterOptions = computed(() => {
const hasSingleplayer = dedupedWorlds.value.some((x) => x.type === 'singleplayer')
const hasServer = dedupedWorlds.value.some((x) => x.type === 'server')
const hasStatusFilter =
selectedFilters.value.includes('online') || selectedFilters.value.includes('offline')
if (hasSingleplayer && hasServer && !hasStatusFilter) {
if (hasSingleplayer && hasServer) {
options.push({ id: 'singleplayer', label: formatMessage(commonMessages.singleplayerLabel) })
}
@@ -633,13 +630,11 @@ const filterOptions = computed(() => {
options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) })
options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) })
}
if (!selectedFilters.value.includes('singleplayer')) {
const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
if (hasOnline && hasOffline) {
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
}
const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
if (hasOnline && hasOffline) {
options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
}
}
@@ -5,16 +5,15 @@
:game-versions="gameVersions"
:versions="versions"
:project="project"
:show-environment-column="themeStore.featureFlags.show_version_environment_column"
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<ButtonStyled circular type="transparent" color="green">
<button
v-tooltip="`Install`"
:class="{
'group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted':
!installed || version.id !== installedVersion,
}"
v-tooltip="
!installed ? 'Install' : version.id !== installedVersion ? 'Swap version' : ''
"
:disabled="installing || (installed && version.id === installedVersion)"
@click.stop="() => install(version.id)"
>
@@ -26,7 +25,6 @@
<ButtonStyled circular type="transparent">
<OverflowMenu
v-if="false"
class="group-hover:!bg-button-bg"
:options="[
{
id: 'install-elsewhere',
@@ -52,7 +50,6 @@
<a
v-else
v-tooltip="`Open in browser`"
class="group-hover:!bg-button-bg"
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
target="_blank"
>
@@ -77,6 +74,9 @@ import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()
defineProps({
project: {
+1
View File
@@ -247,6 +247,7 @@ export default new createRouter({
name: 'Logs',
component: Instance.Logs,
meta: {
renderMode: 'fixed',
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
},
+1
View File
@@ -8,6 +8,7 @@ export const DEFAULT_FEATURE_FLAGS = {
worlds_tab: false,
worlds_in_home: true,
server_project_qa: false,
show_version_environment_column: false,
server_ram_as_bytes_always_on: false,
always_show_app_controls: false,
skip_non_essential_warnings: false,
+6 -1
View File
@@ -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
@@ -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%)',
]
@@ -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,
)
@@ -193,6 +193,7 @@ async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
isLoadingStatusAction.value = true
try {
await client.labrinth.projects_v2.edit(props.item.project.id, { status })
emit('refetch')
projectStatus.value = status
} catch (err) {
@@ -1206,7 +1207,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-if="featureFlags.developerMode" type="outlined">
<button @click="emit('showMaliciousSummary', unsafeFiles)">Debug Summary</button>
<button @click="emit('showMaliciousSummary', unsafeFiles)">Debug</button>
</ButtonStyled>
</template>
</ThreadView>
@@ -21,6 +21,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
developerMode: false,
demoMode: false,
showVersionFilesInTable: false,
showVersionEnvironmentColumn: false,
showAdsWithPlus: false,
alwaysShowChecklistAsPopup: true,
testTaxForm: false,
@@ -914,18 +914,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "يرجى مراجعة بيانات تعريف البيئة"
},
"dashboard.projects.sort.ascending": {
"message": "صعوداً"
},
"dashboard.projects.sort.descending": {
"message": "نزولا"
},
"dashboard.projects.sort.option.name": {
"message": "الإسم"
},
"dashboard.projects.sort.option.status": {
"message": "الحالة"
},
"dashboard.revenue.available-now": {
"message": "متاح الآن"
},
@@ -1346,9 +1346,6 @@
"dashboard.overview.notifications.loading": {
"message": "Načítání oznámení..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Více projektů můžete upravit najednou jejich výběrem níže."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Serverové projekty nepodporují hromadné úpravy"
},
@@ -1400,24 +1397,6 @@
"dashboard.projects.project.moderator-message-aria": {
"message": "Projekt má zprávu od moderátorů. Další informace zobrazíte v projektu."
},
"dashboard.projects.sort.ascending": {
"message": "Vzestupně"
},
"dashboard.projects.sort.descending": {
"message": "Sestupně"
},
"dashboard.projects.sort.option.name": {
"message": "Jméno"
},
"dashboard.projects.sort.option.status": {
"message": "Stav"
},
"dashboard.projects.sort.option.type": {
"message": "Typ"
},
"dashboard.projects.table.icon": {
"message": "Ikona"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1634,9 +1634,6 @@
"dashboard.overview.notifications.loading": {
"message": "Benachrichtigungen werden geladen..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Du kannst mehrere Projekte gleichzeitig bearbeiten, indem du sie unten auswählst."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Serverprojekte understützen Massenbearbeitung nicht"
},
@@ -1712,24 +1709,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Bitte überprüfe die Umgebungsmetadaten"
},
"dashboard.projects.sort.ascending": {
"message": "Aufsteigend"
},
"dashboard.projects.sort.descending": {
"message": "Absteigend"
},
"dashboard.projects.sort.option.name": {
"message": "Name"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.sort.option.type": {
"message": "Art"
},
"dashboard.projects.table.icon": {
"message": "Icon"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1634,9 +1634,6 @@
"dashboard.overview.notifications.loading": {
"message": "Benachrichtigungen werden geladen..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Du kannst mehrere Projekte gleichzeitig bearbeiten, indem du sie unten auswählst."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Serverprojekte unterstützen Massenbearbeitung nicht"
},
@@ -1712,24 +1709,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Bitte überprüfe die Umgebungsmetadaten"
},
"dashboard.projects.sort.ascending": {
"message": "Aufsteigend"
},
"dashboard.projects.sort.descending": {
"message": "Absteigend"
},
"dashboard.projects.sort.option.name": {
"message": "Name"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.sort.option.type": {
"message": "Typ"
},
"dashboard.projects.table.icon": {
"message": "Icon"
},
"dashboard.projects.table.id": {
"message": "ID"
},
+7 -19
View File
@@ -1634,9 +1634,6 @@
"dashboard.overview.notifications.loading": {
"message": "Loading notifications..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "You can edit multiple projects at once by selecting them below."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Server projects do not support bulk editing"
},
@@ -1712,23 +1709,11 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Please review environment metadata"
},
"dashboard.projects.sort.ascending": {
"message": "Ascending"
"dashboard.projects.selection-bar.aria-label": {
"message": "Selected projects actions"
},
"dashboard.projects.sort.descending": {
"message": "Descending"
},
"dashboard.projects.sort.option.name": {
"message": "Name"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.sort.option.type": {
"message": "Type"
},
"dashboard.projects.table.icon": {
"message": "Icon"
"dashboard.projects.selection-bar.selected-count": {
"message": "{count, plural, one {# project selected} other {# projects selected}}"
},
"dashboard.projects.table.id": {
"message": "ID"
@@ -1736,6 +1721,9 @@
"dashboard.projects.table.name": {
"message": "Name"
},
"dashboard.projects.table.select-all-bulk-editable": {
"message": "Select all projects that support bulk editing"
},
"dashboard.projects.table.status": {
"message": "Status"
},
@@ -1634,9 +1634,6 @@
"dashboard.overview.notifications.loading": {
"message": "Cargando notificaciones..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Puedes editar varios proyectos a la vez seleccionándolos a continuación."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Los proyectos no son compatible con la edición masiva de servidores"
},
@@ -1712,24 +1709,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Por favor revisa los metadatos de entorno"
},
"dashboard.projects.sort.ascending": {
"message": "Ascendente"
},
"dashboard.projects.sort.descending": {
"message": "Descendente"
},
"dashboard.projects.sort.option.name": {
"message": "Nombre"
},
"dashboard.projects.sort.option.status": {
"message": "Estado"
},
"dashboard.projects.sort.option.type": {
"message": "Tipo"
},
"dashboard.projects.table.icon": {
"message": "Icono"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1517,9 +1517,6 @@
"dashboard.overview.notifications.loading": {
"message": "Cargando notificaciones..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Puedes editar múltiples proyectos a la vez seleccionándolos abajo."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Proyectos de servidor no soportan edición masiva"
},
@@ -1592,24 +1589,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Por favor revise el entorno del metadata"
},
"dashboard.projects.sort.ascending": {
"message": "Ascendiendo"
},
"dashboard.projects.sort.descending": {
"message": "Descendiendo"
},
"dashboard.projects.sort.option.name": {
"message": "Nombre"
},
"dashboard.projects.sort.option.status": {
"message": "Estado"
},
"dashboard.projects.sort.option.type": {
"message": "Tipo"
},
"dashboard.projects.table.icon": {
"message": "Icono"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -611,24 +611,6 @@
"dashboard.projects.project.icon-alt": {
"message": "Kuvake projektille {title}"
},
"dashboard.projects.sort.ascending": {
"message": "Nouseva"
},
"dashboard.projects.sort.descending": {
"message": "Laskeva"
},
"dashboard.projects.sort.option.name": {
"message": "Nimi"
},
"dashboard.projects.sort.option.status": {
"message": "Tila"
},
"dashboard.projects.sort.option.type": {
"message": "Tyyppi"
},
"dashboard.projects.table.icon": {
"message": "Kuvake"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -998,18 +998,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Pakisuri sa metadata sa environment"
},
"dashboard.projects.sort.option.name": {
"message": "Pangalan"
},
"dashboard.projects.sort.option.status": {
"message": "Estado"
},
"dashboard.projects.sort.option.type": {
"message": "Uri"
},
"dashboard.projects.table.icon": {
"message": "Ikono"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1634,9 +1634,6 @@
"dashboard.overview.notifications.loading": {
"message": "Chargement des notifications..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Vous pouvez modifier plusieurs projets en même temps en les sélectionnant ci-dessous."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Les projets du serveur ne prennent pas en charge l'édition en masse"
},
@@ -1712,24 +1709,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Merci de revoir les métadonnées environnementales"
},
"dashboard.projects.sort.ascending": {
"message": "Ascendant"
},
"dashboard.projects.sort.descending": {
"message": "Descendant"
},
"dashboard.projects.sort.option.name": {
"message": "Nom"
},
"dashboard.projects.sort.option.status": {
"message": "Statut"
},
"dashboard.projects.sort.option.type": {
"message": "Type"
},
"dashboard.projects.table.icon": {
"message": "Icône"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1382,9 +1382,6 @@
"dashboard.overview.notifications.loading": {
"message": "Értesítések betöltése..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Több projektet is szerkeszthetsz egyszerre, ha kiválasztod őket alább."
},
"dashboard.projects.empty": {
"message": "Még nincsenek projektjeid. Kattints a fenti zöld gombra a kezdéshez."
},
@@ -1445,24 +1442,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Kérlek ellenőrizd a környezeti metaadatokat"
},
"dashboard.projects.sort.ascending": {
"message": "Növekvő"
},
"dashboard.projects.sort.descending": {
"message": "Csökkenő"
},
"dashboard.projects.sort.option.name": {
"message": "Név"
},
"dashboard.projects.sort.option.status": {
"message": "Állapot"
},
"dashboard.projects.sort.option.type": {
"message": "Típus"
},
"dashboard.projects.table.icon": {
"message": "Ikon"
},
"dashboard.projects.table.id": {
"message": "Azonosító"
},
@@ -1628,9 +1628,6 @@
"dashboard.overview.notifications.loading": {
"message": "Caricando le notifiche..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Puoi modificare più progetti contemporaneamente selezionandoli qui sotto."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Non puoi modificare più server contemporaneamente"
},
@@ -1703,24 +1700,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Si prega di rivedere i metadati d'ambiente"
},
"dashboard.projects.sort.ascending": {
"message": "Crescente"
},
"dashboard.projects.sort.descending": {
"message": "Decrescente"
},
"dashboard.projects.sort.option.name": {
"message": "Nome"
},
"dashboard.projects.sort.option.status": {
"message": "Stato"
},
"dashboard.projects.sort.option.type": {
"message": "Tipo"
},
"dashboard.projects.table.icon": {
"message": "Icona"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1211,9 +1211,6 @@
"dashboard.overview.notifications.loading": {
"message": "通知を読み込み中…"
},
"dashboard.projects.bulk-edit-hint": {
"message": "以下で選択することで、複数のプロジェクトを一括で編集できます。"
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "サーバープロジェクトでは一括編集を利用できません"
},
@@ -1286,24 +1283,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "環境メタデータを確認してください"
},
"dashboard.projects.sort.ascending": {
"message": "昇順"
},
"dashboard.projects.sort.descending": {
"message": "降順"
},
"dashboard.projects.sort.option.name": {
"message": "名前"
},
"dashboard.projects.sort.option.status": {
"message": "ステータス"
},
"dashboard.projects.sort.option.type": {
"message": "種類"
},
"dashboard.projects.table.icon": {
"message": "アイコン"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -998,9 +998,6 @@
"dashboard.overview.notifications.loading": {
"message": "알림 불러오는 중..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "아래에서 여러 프로젝트를 선택하면 한 번에 편집할 수 있습니다."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "서버 프로젝트는 일괄 편집을 지원하지 않습니다"
},
@@ -1070,24 +1067,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "환경 메타데이터를 검토해 주세요"
},
"dashboard.projects.sort.ascending": {
"message": "오름차순"
},
"dashboard.projects.sort.descending": {
"message": "내림차순"
},
"dashboard.projects.sort.option.name": {
"message": "이름"
},
"dashboard.projects.sort.option.status": {
"message": "상태"
},
"dashboard.projects.sort.option.type": {
"message": "유형"
},
"dashboard.projects.table.icon": {
"message": "아이콘"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1367,9 +1367,6 @@
"dashboard.overview.notifications.loading": {
"message": "Sedang memuat pemberitahuan..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Anda boleh menyunting berbilang projek sekaligus dengan memilihnya di bawah."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Projek pelayan tidak menyokong penyuntingan pukal"
},
@@ -1439,24 +1436,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Sila semak metadata persekitaran"
},
"dashboard.projects.sort.ascending": {
"message": "Menaik"
},
"dashboard.projects.sort.descending": {
"message": "Menurun"
},
"dashboard.projects.sort.option.name": {
"message": "Nama"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.sort.option.type": {
"message": "Jenis"
},
"dashboard.projects.table.icon": {
"message": "Ikon"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1358,9 +1358,6 @@
"dashboard.overview.notifications.loading": {
"message": "Notificaties aan het laden..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "U kunt meerdere projecten tegelijkertijd bijwerken door ze hieronder te selecteren."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Massa wijzigingen worden niet ondersteund door server projecten"
},
@@ -1412,12 +1409,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Controller project metadata alstublieft"
},
"dashboard.projects.sort.option.name": {
"message": "Naam"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.table.name": {
"message": "Naam"
},
@@ -851,9 +851,6 @@
"dashboard.organizations.title": {
"message": "Organiseringer"
},
"dashboard.projects.bulk-edit-hint": {
"message": "Du kan redigere flere prosjekt samtidig ved å velge dem nedafor."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Serverprosjekt støtter ikke massredigering"
},
@@ -1634,9 +1634,6 @@
"dashboard.overview.notifications.loading": {
"message": "Ładowanie powiadomień..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Możesz edytować kilka projektów naraz, zaznaczając je poniżej."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Projekty serwerów nie wspierają grupowej edycji"
},
@@ -1709,24 +1706,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Sprawdź metadane środowiska"
},
"dashboard.projects.sort.ascending": {
"message": "Rosnąco"
},
"dashboard.projects.sort.descending": {
"message": "Malejąco"
},
"dashboard.projects.sort.option.name": {
"message": "Nazwa"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.sort.option.type": {
"message": "Rodzaj"
},
"dashboard.projects.table.icon": {
"message": "Ikona"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1631,9 +1631,6 @@
"dashboard.overview.notifications.loading": {
"message": "Carregando notificações..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Você pode editar vários projetos em simultâneo, selecionando-os abaixo."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Os projetos de servidor não suportam edição em massa"
},
@@ -1709,24 +1706,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Por favor, revise os metadados do ambiente"
},
"dashboard.projects.sort.ascending": {
"message": "Crescente"
},
"dashboard.projects.sort.descending": {
"message": "Decrescente"
},
"dashboard.projects.sort.option.name": {
"message": "Nome"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.sort.option.type": {
"message": "Tipo"
},
"dashboard.projects.table.icon": {
"message": "Ícone"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -821,18 +821,6 @@
"dashboard.projects.links.wiki-page.label": {
"message": "Página da Wiki"
},
"dashboard.projects.sort.option.name": {
"message": "Nome"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.sort.option.type": {
"message": "Tipo"
},
"dashboard.projects.table.icon": {
"message": "Ícone"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1628,9 +1628,6 @@
"dashboard.overview.notifications.loading": {
"message": "Загрузка уведомлений..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Можно редактировать сразу несколько проектов, выбрав их ниже."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Серверные проекты не поддерживают массовое редактирование"
},
@@ -1706,24 +1703,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Проверьте метаданные среды"
},
"dashboard.projects.sort.ascending": {
"message": "По возрастанию"
},
"dashboard.projects.sort.descending": {
"message": "По убыванию"
},
"dashboard.projects.sort.option.name": {
"message": "Название"
},
"dashboard.projects.sort.option.status": {
"message": "Статус"
},
"dashboard.projects.sort.option.type": {
"message": "Тип"
},
"dashboard.projects.table.icon": {
"message": "Иконка"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1514,24 +1514,6 @@
"dashboard.projects.project.icon-alt": {
"message": "Ikon för {title}"
},
"dashboard.projects.sort.ascending": {
"message": "Stigande"
},
"dashboard.projects.sort.descending": {
"message": "Fallande"
},
"dashboard.projects.sort.option.name": {
"message": "Namn"
},
"dashboard.projects.sort.option.status": {
"message": "Status"
},
"dashboard.projects.sort.option.type": {
"message": "Typ"
},
"dashboard.projects.table.icon": {
"message": "Ikon"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1574,9 +1574,6 @@
"dashboard.overview.notifications.loading": {
"message": "Bildirimler yükleniyor..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Aşağıdan seçerek birden fazla projeyi düzenleyebilirsiniz."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Sunucu projeleri toplu düzenlemeyi desteklemez"
},
@@ -1652,24 +1649,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Lütfen ortam meta verilerini inceleyin"
},
"dashboard.projects.sort.ascending": {
"message": "Artan"
},
"dashboard.projects.sort.descending": {
"message": "Azalan sırada sırala"
},
"dashboard.projects.sort.option.name": {
"message": "İsim"
},
"dashboard.projects.sort.option.status": {
"message": "Durum"
},
"dashboard.projects.sort.option.type": {
"message": "Tip"
},
"dashboard.projects.table.icon": {
"message": "Simge"
},
"dashboard.projects.table.id": {
"message": "Kimlik"
},
@@ -1301,9 +1301,6 @@
"dashboard.overview.notifications.loading": {
"message": "Завантаження сповіщень…"
},
"dashboard.projects.bulk-edit-hint": {
"message": "Ви можете редагувати кілька проєктів одночасно, вибравши їх нижче."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Проєкти сервера не підтримують масове редагування"
},
@@ -1376,24 +1373,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Перегляньте метадані середовища"
},
"dashboard.projects.sort.ascending": {
"message": "Зростання"
},
"dashboard.projects.sort.descending": {
"message": "Спадання"
},
"dashboard.projects.sort.option.name": {
"message": "Назва"
},
"dashboard.projects.sort.option.status": {
"message": "Статус"
},
"dashboard.projects.sort.option.type": {
"message": "Тип"
},
"dashboard.projects.table.icon": {
"message": "Значок"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1478,9 +1478,6 @@
"dashboard.overview.notifications.loading": {
"message": "Đang tải thông báo..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "Bạn có thể sửa đổi nhiều dự án cùng một lúc bằng cách chọn chúng ở dưới."
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "Dự án máy chủ không hỗ trợ sửa đổi nhiều mục cùng một lúc"
},
@@ -1556,24 +1553,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "Vui lòng xem lại thông tin dữ liệu"
},
"dashboard.projects.sort.ascending": {
"message": "Từ dưới lên"
},
"dashboard.projects.sort.descending": {
"message": "Từ trên xuống"
},
"dashboard.projects.sort.option.name": {
"message": "Tên"
},
"dashboard.projects.sort.option.status": {
"message": "Trạng thái"
},
"dashboard.projects.sort.option.type": {
"message": "Loại"
},
"dashboard.projects.table.icon": {
"message": "B.tượng"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1631,9 +1631,6 @@
"dashboard.overview.notifications.loading": {
"message": "加载通知…"
},
"dashboard.projects.bulk-edit-hint": {
"message": "你可以通过下方复选框选择多个项目来一次性编辑。"
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "服务器项目不支持批量编辑"
},
@@ -1709,24 +1706,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "请复核运行环境元数据"
},
"dashboard.projects.sort.ascending": {
"message": "正序"
},
"dashboard.projects.sort.descending": {
"message": "降序"
},
"dashboard.projects.sort.option.name": {
"message": "名称"
},
"dashboard.projects.sort.option.status": {
"message": "状态"
},
"dashboard.projects.sort.option.type": {
"message": "类型"
},
"dashboard.projects.table.icon": {
"message": "图标"
},
"dashboard.projects.table.id": {
"message": "ID"
},
@@ -1634,9 +1634,6 @@
"dashboard.overview.notifications.loading": {
"message": "正在載入通知..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "你可以透過在下方選擇專案來同時編輯多個專案。"
},
"dashboard.projects.bulk-edit.server-disabled": {
"message": "伺服器專案不支援大量編輯"
},
@@ -1712,24 +1709,6 @@
"dashboard.projects.project.review-environment-metadata": {
"message": "請審查環境詮釋資料"
},
"dashboard.projects.sort.ascending": {
"message": "遞增"
},
"dashboard.projects.sort.descending": {
"message": "遞減"
},
"dashboard.projects.sort.option.name": {
"message": "名稱"
},
"dashboard.projects.sort.option.status": {
"message": "狀態"
},
"dashboard.projects.sort.option.type": {
"message": "類型"
},
"dashboard.projects.table.icon": {
"message": "圖示"
},
"dashboard.projects.table.id": {
"message": "ID"
},
+1 -1
View File
@@ -1017,7 +1017,7 @@
</div>
<div class="normal-page__content">
<div class="overflow-x-auto"><NavTabs :links="navLinks" replace class="mb-4" /></div>
<div class="mb-3 overflow-x-auto"><NavTabs :links="navLinks" replace class="mb-1" /></div>
<NuxtPage @on-download="triggerDownloadAnimation" @delete-version="deleteVersion" />
</div>
</div>
@@ -53,6 +53,7 @@
:project="project"
:versions="versionsWithDisplayUrl"
:show-files="flags.showVersionFilesInTable"
:show-environment-column="flags.showVersionEnvironmentColumn"
:current-member="!!currentMember"
:loaders="tags.loaders"
:game-versions="tags.gameVersions"
@@ -27,6 +27,7 @@
:project="project"
:versions="versions"
:show-files="flags.showVersionFilesInTable"
:show-environment-column="flags.showVersionEnvironmentColumn"
:current-member="!!currentMember"
:loaders="tags.loaders"
:game-versions="tags.gameVersions"
+314 -351
View File
@@ -157,181 +157,215 @@
</div>
</NewModal>
<ModalCreation ref="modal_creation" />
<section class="universal-card">
<div class="header__row">
<h2 class="header__title text-2xl">{{ formatMessage(messages.headTitle) }}</h2>
<div class="input-group">
<ButtonStyled color="brand">
<button @click="$refs.modal_creation.show($event)">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
</ButtonStyled>
</div>
</div>
<p v-if="projects.length < 1">
{{ formatMessage(messages.noProjectsYet) }}
</p>
<template v-else>
<p>{{ formatMessage(messages.bulkEditHint) }}</p>
<div class="input-group">
<ButtonStyled>
<button :disabled="selectedProjects.length === 0" @click="$refs.editLinksModal.show()">
<EditIcon />
{{ formatMessage(messages.editLinksButton) }}
</button>
</ButtonStyled>
<div class="push-right">
<div class="labeled-control-row">
{{ formatMessage(commonMessages.sortByLabel) }}
<Combobox
v-model="sortBy"
:searchable="false"
class="small-select"
:options="sortOptions"
@update:model-value="projects = updateSort(projects, sortBy, descending)"
/>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(descending ? messages.descending : messages.ascending)"
@click="updateDescending()"
>
<SortDescIcon v-if="descending" />
<SortAscIcon v-else />
<section class="relative overflow-hidden rounded-2xl">
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="projectTableColumns"
:data="sortedProjects"
row-key="id"
table-min-width="50rem"
table-layout="auto"
>
<template #header>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h2 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.headTitle) }}
</h2>
<div class="flex w-full flex-wrap items-center gap-2 md:w-auto">
<ButtonStyled color="brand">
<button @click="$refs.modal_creation.show($event)">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="grid-table">
<div class="grid-table__row grid-table__header">
<div>
<Checkbox
:model-value="allBulkEditableProjectsSelected"
@update:model-value="toggleAllBulkEditableProjects()"
/>
</div>
<div>{{ formatMessage(messages.iconHeader) }}</div>
<div>{{ formatMessage(messages.nameHeader) }}</div>
<div>{{ formatMessage(messages.idHeader) }}</div>
<div>{{ formatMessage(messages.typeHeader) }}</div>
<div>{{ formatMessage(messages.statusHeader) }}</div>
<div />
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
{{ formatMessage(messages.noProjectsYet) }}
</div>
<div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
<div>
<Checkbox
v-tooltip="getBulkEditDisabledTooltip(project)"
:disabled="isProjectBulkEditDisabled(project)"
:model-value="selectedProjects.includes(project)"
@update:model-value="toggleProjectSelection(project)"
</template>
<template #header-select>
<div v-tooltip="formatMessage(messages.selectAllBulkEditableProjects)">
<Checkbox
:model-value="allBulkEditableProjectsSelected"
@update:model-value="toggleAllBulkEditableProjects()"
/>
</div>
</template>
<template #cell-select="{ row: project }">
<div>
<Checkbox
v-tooltip="getBulkEditDisabledTooltip(project)"
:disabled="isProjectBulkEditDisabled(project)"
:model-value="isProjectSelected(project)"
@update:model-value="toggleProjectSelection(project)"
/>
</div>
</template>
<template #cell-name="{ row: project }">
<nuxt-link class="project-name-cell" :to="getProjectUrl(project)">
<span
v-tooltip="project.title"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded text-primary"
>
<img
v-if="project.icon_url"
:src="project.icon_url"
:alt="formatMessage(messages.projectIconAlt, { title: project.title })"
class="h-6 w-6 rounded object-cover"
/>
<BoxIcon v-else class="h-full w-full" />
</span>
<span class="project-title">
<IssuesIcon
v-if="project.moderator_message"
:aria-label="formatMessage(messages.projectModeratorMessageAriaLabel)"
/>
</div>
<div>
<nuxt-link
tabindex="-1"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}`"
>
<Avatar
:src="project.icon_url"
aria-hidden="true"
:alt="formatMessage(messages.projectIconAlt, { title: project.title })"
no-shadow
/>
</nuxt-link>
</div>
<div>
<span class="project-title">
<IssuesIcon
v-if="project.moderator_message"
:aria-label="formatMessage(messages.projectModeratorMessageAriaLabel)"
/>
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}`"
>
{{ project.title }}
</nuxt-link>
<span class="project-title-link wrap-as-needed">
{{ project.title }}
</span>
</div>
<div>
<CopyCode :text="project.id" />
</div>
<div>
{{ formatProjectType(getProjectTypeForUrl(project.project_type, project.loaders)) }}
</div>
<div>
<ProjectStatusBadge v-if="project.status" :status="project.status" />
</div>
<div class="flex !flex-row items-center !justify-end gap-2">
<ButtonStyled
v-if="projectsWithMigrationWarning.includes(project.id)"
circular
color="orange"
</span>
</nuxt-link>
</template>
<template #cell-id="{ row: project }">
<div class="flex items-center">
<CopyCode :text="project.id" />
</div>
</template>
<template #cell-type="{ row: project }">
<div class="flex items-center">
{{ getProjectDisplayType(project) }}
</div>
</template>
<template #cell-status="{ row: project }">
<div class="flex items-center">
<ProjectStatusBadge v-if="project.status" :status="project.status" />
</div>
</template>
<template #cell-actions="{ row: project }">
<div class="flex !flex-row items-center !justify-end gap-2">
<ButtonStyled
v-if="projectsWithMigrationWarning.includes(project.id)"
circular
color="orange"
>
<nuxt-link
v-tooltip="formatMessage(messages.reviewEnvironmentMetadata)"
:to="`${getProjectUrl(project)}?showEnvironmentMigrationWarning=true`"
>
<nuxt-link
v-tooltip="formatMessage(messages.reviewEnvironmentMetadata)"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}?showEnvironmentMigrationWarning=true`"
>
<TriangleAlertIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link
v-tooltip="formatMessage(commonMessages.settingsLabel)"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings`"
>
<SettingsIcon />
</nuxt-link>
</ButtonStyled>
</div>
<TriangleAlertIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link
v-tooltip="formatMessage(commonMessages.settingsLabel)"
:to="`${getProjectUrl(project)}/settings`"
>
<SettingsIcon />
</nuxt-link>
</ButtonStyled>
</div>
</template>
</Table>
</section>
<FloatingActionBar
:shown="selectedProjects.length > 0"
:aria-label="formatMessage(messages.selectionActionBarAriaLabel)"
hide-when-modal-open
>
<div class="flex items-center gap-0.5">
<div
class="relative h-8 shrink-0"
:style="{ width: `${selectedProjectIconStackWidth}px` }"
aria-hidden="true"
>
<div
v-for="(project, index) in visibleSelectedProjects"
:key="project.id"
v-tooltip="project.title"
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
:style="{
left: `${index * SELECTED_PROJECT_ICON_STACK_OFFSET}px`,
zIndex: visibleSelectedProjects.length - index,
}"
>
<img
v-if="project.icon_url"
:src="project.icon_url"
:alt="formatMessage(messages.projectIconAlt, { title: project.title })"
class="h-full w-full rounded-lg object-cover"
/>
<BoxIcon v-else class="h-full w-full text-primary" />
</div>
<div
v-if="selectedProjectOverflowCount > 0"
class="absolute top-0 flex h-8 w-8 items-center justify-center rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4 text-xs font-bold text-contrast"
:style="{
left: `${visibleSelectedProjects.length * SELECTED_PROJECT_ICON_STACK_OFFSET}px`,
zIndex: 0,
}"
>
+{{ selectedProjectOverflowCount }}
</div>
</div>
</template>
</section>
<span class="px-3 py-2 text-base font-semibold tabular-nums text-contrast">
{{ formatMessage(messages.selectedProjectsCount, { count: selectedProjects.length }) }}
</span>
<div class="mx-0.5 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button
v-tooltip="formatMessage(commonMessages.clearButton)"
class="!text-primary"
@click="clearProjectSelection()"
>
<XIcon class="cq-show-icon hidden" />
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
</button>
</ButtonStyled>
</div>
<div class="ml-auto flex items-center gap-0.5">
<ButtonStyled color="brand">
<button v-tooltip="formatMessage(messages.editLinksButton)" @click="showEditLinksModal()">
<EditIcon />
<span class="bar-label">{{ formatMessage(messages.editLinksButton) }}</span>
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
</div>
</template>
<script setup>
import {
BoxIcon,
EditIcon,
IssuesIcon,
PlusIcon,
SaveIcon,
SettingsIcon,
SortAscIcon,
SortDescIcon,
TrashIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Checkbox,
Combobox,
commonMessages,
CopyCode,
defineMessages,
FloatingActionBar,
injectNotificationManager,
IntlFormatted,
NewModal,
ProjectStatusBadge,
StyledInput,
Table,
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
@@ -360,6 +394,14 @@ const messages = defineMessages({
id: 'dashboard.projects.links.button.edit',
defaultMessage: 'Edit links',
},
selectedProjectsCount: {
id: 'dashboard.projects.selection-bar.selected-count',
defaultMessage: '{count, plural, one {# project selected} other {# projects selected}}',
},
selectionActionBarAriaLabel: {
id: 'dashboard.projects.selection-bar.aria-label',
defaultMessage: 'Selected projects actions',
},
editLinksHeader: {
id: 'dashboard.projects.links.header.edit',
defaultMessage: 'Edit links',
@@ -434,34 +476,6 @@ const messages = defineMessages({
id: 'dashboard.projects.empty',
defaultMessage: "You don't have any projects yet. Click the green button above to begin.",
},
bulkEditHint: {
id: 'dashboard.projects.bulk-edit-hint',
defaultMessage: 'You can edit multiple projects at once by selecting them below.',
},
ascending: {
id: 'dashboard.projects.sort.ascending',
defaultMessage: 'Ascending',
},
descending: {
id: 'dashboard.projects.sort.descending',
defaultMessage: 'Descending',
},
sortOptionName: {
id: 'dashboard.projects.sort.option.name',
defaultMessage: 'Name',
},
sortOptionStatus: {
id: 'dashboard.projects.sort.option.status',
defaultMessage: 'Status',
},
sortOptionType: {
id: 'dashboard.projects.sort.option.type',
defaultMessage: 'Type',
},
iconHeader: {
id: 'dashboard.projects.table.icon',
defaultMessage: 'Icon',
},
nameHeader: {
id: 'dashboard.projects.table.name',
defaultMessage: 'Name',
@@ -478,6 +492,10 @@ const messages = defineMessages({
id: 'dashboard.projects.table.status',
defaultMessage: 'Status',
},
selectAllBulkEditableProjects: {
id: 'dashboard.projects.table.select-all-bulk-editable',
defaultMessage: 'Select all projects that support bulk editing',
},
projectIconAlt: {
id: 'dashboard.projects.project.icon-alt',
defaultMessage: 'Icon for {title}',
@@ -505,14 +523,9 @@ useHead({ title: () => `${formatMessage(messages.headTitle)} - Modrinth` })
const user = await useUser()
const projects = ref([])
const projectsWithMigrationWarning = ref([])
const selectedProjects = ref([])
const sortBy = ref('Name')
const sortOptions = computed(() => [
{ value: 'Name', label: formatMessage(messages.sortOptionName) },
{ value: 'Status', label: formatMessage(messages.sortOptionStatus) },
{ value: 'Type', label: formatMessage(messages.sortOptionType) },
])
const descending = ref(false)
const selectedProjectIds = ref([])
const sortColumn = ref('name')
const sortDirection = ref('asc')
const editLinks = reactive({
showAffected: false,
source: { val: '', clear: false },
@@ -522,7 +535,62 @@ const editLinks = reactive({
})
const editLinksModal = ref(null)
const modal_creation = ref(null)
const sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const SELECTED_PROJECT_ICON_STACK_OFFSET = 24
const projectTableColumns = computed(() => [
{
key: 'select',
width: '3rem',
headerClass: '!text-center',
cellClass: '!overflow-visible',
},
{
key: 'name',
label: formatMessage(messages.nameHeader),
enableSorting: true,
defaultSortDirection: 'asc',
width: '22rem',
},
{
key: 'id',
label: formatMessage(messages.idHeader),
width: '13rem',
cellClass: '!overflow-visible',
},
{
key: 'type',
label: formatMessage(messages.typeHeader),
enableSorting: true,
width: '11rem',
},
{
key: 'status',
label: formatMessage(messages.statusHeader),
enableSorting: true,
width: '10rem',
cellClass: '!overflow-visible',
},
{
key: 'actions',
width: '6rem',
align: 'right',
cellClass: '!overflow-visible',
},
])
const sortedProjects = computed(() => {
const direction = sortDirection.value === 'desc' ? -1 : 1
return projects.value.slice().sort((left, right) => {
const result = sortCollator.compare(
getProjectSortValue(left, sortColumn.value),
getProjectSortValue(right, sortColumn.value),
)
return result * direction
})
})
function getLinkInputPlaceholder(clearLink, isDiscord = false) {
if (clearLink) {
@@ -545,16 +613,35 @@ const bulkEditableProjects = computed(() =>
projects.value.filter((project) => !isProjectBulkEditDisabled(project)),
)
const selectedProjects = computed(() =>
projects.value.filter((project) => selectedProjectIds.value.includes(project.id)),
)
const visibleSelectedProjects = computed(() => selectedProjects.value.slice(0, 3))
const selectedProjectOverflowCount = computed(() => Math.max(0, selectedProjects.value.length - 3))
const selectedProjectIconStackWidth = computed(() => {
if (selectedProjects.value.length === 0) return 0
return (
32 +
(visibleSelectedProjects.value.length - 1 + (selectedProjectOverflowCount.value > 0 ? 1 : 0)) *
SELECTED_PROJECT_ICON_STACK_OFFSET
)
})
const allBulkEditableProjectsSelected = computed(
() =>
bulkEditableProjects.value.length > 0 &&
bulkEditableProjects.value.every((project) => selectedProjects.value.includes(project)),
bulkEditableProjects.value.every((project) => selectedProjectIds.value.includes(project.id)),
)
function toggleAllBulkEditableProjects() {
selectedProjects.value = allBulkEditableProjectsSelected.value
selectedProjectIds.value = allBulkEditableProjectsSelected.value
? []
: bulkEditableProjects.value.slice()
: bulkEditableProjects.value.map((project) => project.id)
}
function isProjectSelected(project) {
return selectedProjectIds.value.includes(project.id)
}
function toggleProjectSelection(project) {
@@ -562,12 +649,24 @@ function toggleProjectSelection(project) {
return
}
if (selectedProjects.value.includes(project)) {
selectedProjects.value = selectedProjects.value.filter((it) => it !== project)
if (isProjectSelected(project)) {
selectedProjectIds.value = selectedProjectIds.value.filter((id) => id !== project.id)
return
}
selectedProjects.value = [...selectedProjects.value, project]
selectedProjectIds.value = [...selectedProjectIds.value, project.id]
}
function clearProjectSelection() {
selectedProjectIds.value = []
}
function showEditLinksModal() {
if (selectedProjects.value.length === 0) {
return
}
editLinksModal.value?.show()
}
function getBulkEditDisabledTooltip(project) {
@@ -578,40 +677,28 @@ function getBulkEditDisabledTooltip(project) {
return ''
}
function updateSort(list, sort, desc) {
let sortedArray = list
switch (sort) {
case 'Name':
sortedArray = list.slice().sort((a, b) => a.title.localeCompare(b.title))
break
case 'Status':
sortedArray = list.slice().sort((a, b) => {
if (a.status < b.status) return -1
if (a.status > b.status) return 1
return 0
})
break
case 'Type':
sortedArray = list.slice().sort((a, b) => {
if (a.project_type < b.project_type) return -1
if (a.project_type > b.project_type) return 1
return 0
})
break
function getProjectUrlType(project) {
return getProjectTypeForUrl(project.project_type, project.loaders)
}
function getProjectDisplayType(project) {
return formatProjectType(getProjectUrlType(project))
}
function getProjectUrl(project) {
return `/${getProjectUrlType(project)}/${project.slug ? project.slug : project.id}`
}
function getProjectSortValue(project, column) {
switch (column) {
case 'type':
return getProjectDisplayType(project)
case 'status':
return project.status ?? ''
case 'name':
default:
break
return project.title ?? ''
}
if (desc) sortedArray = sortedArray.reverse()
return sortedArray
}
function resort() {
projects.value = updateSort(projects.value, sortBy.value, descending.value)
}
function updateDescending() {
descending.value = !descending.value
resort()
}
async function bulkEditLinks() {
@@ -635,7 +722,7 @@ async function bulkEditLinks() {
text: formatMessage(messages.bulkEditSuccessText),
type: 'success',
})
selectedProjects.value = []
selectedProjectIds.value = []
editLinks.issues.val = ''
editLinks.source.val = ''
@@ -656,7 +743,7 @@ async function bulkEditLinks() {
await initUserProjects()
if (user.value?.projects) {
projects.value = updateSort(user.value.projects, 'Name', false)
projects.value = user.value.projects.slice()
// minecraft_java_server type determined from component on projectV3
projects.value = projects.value.map((project) => {
@@ -677,126 +764,20 @@ if (user.value?.projects) {
}
</script>
<style lang="scss" scoped>
.grid-table {
display: grid;
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
border-radius: var(--size-rounded-sm);
overflow: hidden;
margin-top: var(--spacing-card-md);
outline: 1px solid transparent;
.grid-table__row {
display: contents;
> div {
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--spacing-card-sm);
// Left edge of table
&:first-child {
padding-left: var(--spacing-card-bg);
}
// Right edge of table
&:last-child {
padding-right: var(--spacing-card-bg);
}
}
&:nth-child(2n + 1) > div {
background-color: var(--color-table-alternate-row);
}
&.grid-table__header > div {
background-color: var(--color-bg);
font-weight: bold;
color: var(--color-text-dark);
padding-top: var(--spacing-card-bg);
padding-bottom: var(--spacing-card-bg);
}
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
.grid-table__row {
display: grid;
grid-template: 'checkbox icon name type settings' 'checkbox icon id status settings';
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
:nth-child(1) {
grid-area: checkbox;
}
:nth-child(2) {
grid-area: icon;
}
:nth-child(3) {
grid-area: name;
}
:nth-child(4) {
grid-area: id;
padding-top: 0;
}
:nth-child(5) {
grid-area: type;
}
:nth-child(6) {
grid-area: status;
padding-top: 0;
}
:nth-child(7) {
grid-area: settings;
}
}
.grid-table__header {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
:nth-child(2),
:nth-child(3),
:nth-child(4),
:nth-child(5),
:nth-child(6) {
display: none;
}
}
}
@media screen and (max-width: 560px) {
.grid-table__row {
display: grid;
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
:nth-child(5) {
padding-top: 0;
}
}
.grid-table__header {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
}
}
.project-name-cell {
display: flex;
min-width: 0;
min-height: 3.5rem;
align-items: center;
gap: var(--spacing-card-sm);
color: inherit;
text-decoration: none;
}
.project-title {
display: flex;
flex-direction: row;
min-width: 0;
gap: var(--spacing-card-xs);
svg {
@@ -804,29 +785,11 @@ if (user.value?.projects) {
}
}
.status {
margin-top: var(--spacing-card-xs);
}
.hover-link:hover {
.project-name-cell:hover .project-title-link,
.project-name-cell:focus-visible .project-title-link {
text-decoration: underline;
}
.labeled-control-row {
flex: 1;
display: flex;
flex-direction: row;
min-width: 0;
align-items: center;
gap: var(--spacing-card-md);
white-space: nowrap;
}
.small-select {
width: -moz-fit-content;
width: fit-content;
}
.label-button[data-active='true'] {
--background-color: var(--color-red);
--text-color: var(--color-brand-inverted);
+40 -29
View File
@@ -183,24 +183,24 @@
>
<div class="flex flex-col gap-6">
<template v-if="auth.user.has_totp && twoFactorStep === 0">
<label for="two-factor-code">
<div class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">{{
formatMessage(messages.twoFactorEnterCodeLabel)
}}</span>
<StyledInput
id="two-factor-code"
v-model="twoFactorCode"
:maxlength="11"
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
@keyup.enter="removeTwoFactor()"
/>
<span class="label__description">{{
formatMessage(messages.twoFactorEnterCodeDescription)
}}</span>
</label>
<StyledInput
id="two-factor-code"
v-model="twoFactorCode"
:maxlength="11"
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
@keyup.enter="removeTwoFactor()"
/>
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
</div>
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.manageTwoFactorModal.hide()">
@@ -222,12 +222,13 @@
<p class="m-0">
<IntlFormatted :message-id="messages.twoFactorSetupScan">
<template #authy-link="{ children }">
<a href="https://authy.com/" target="_blank" rel="noreferrer">
<a class="underline" href="https://authy.com/" target="_blank" rel="noreferrer">
<component :is="() => children" />
</a>
</template>
<template #microsoft-authenticator-link="{ children }">
<a
class="underline"
href="https://www.microsoft.com/en-us/security/mobile-authenticator-app"
target="_blank"
rel="noreferrer"
@@ -252,25 +253,22 @@
</p>
</template>
<template v-if="twoFactorStep === 1">
<label for="verify-code">
<div class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">{{
formatMessage(messages.twoFactorVerifyCodeLabel)
}}</span>
<TwoFactorAuthCodeInput
ref="twoFactorCodeInput"
v-model="twoFactorCode"
@keyup.enter="verifyTwoFactorCode()"
/>
<span class="label__description">{{
formatMessage(messages.twoFactorVerifyCodeDescription)
}}</span>
</label>
<StyledInput
id="verify-code"
v-model="twoFactorCode"
:maxlength="6"
autocomplete="one-time-code"
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
@keyup.enter="verifyTwoFactorCode()"
/>
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
</div>
</template>
<template v-if="twoFactorStep === 2">
<p class="m-0">{{ formatMessage(messages.twoFactorBackupCodesIntro) }}</p>
@@ -478,6 +476,7 @@
</template>
<script setup>
import { nextTick, watch } from 'vue'
import {
CheckIcon,
DownloadIcon,
@@ -503,6 +502,7 @@ import {
NewModal,
StyledInput,
Table,
TwoFactorAuthCodeInput,
useVIntl,
} from '@modrinth/ui'
import KeyIcon from 'assets/icons/auth/key.svg'
@@ -878,9 +878,10 @@ const manageTwoFactorModal = ref()
const twoFactorSecret = ref(null)
const twoFactorFlow = ref(null)
const twoFactorStep = ref(0)
const twoFactorCodeInput = ref()
async function showTwoFactorModal() {
twoFactorStep.value = 0
twoFactorCode.value = null
twoFactorCode.value = ''
twoFactorIncorrect.value = false
if (auth.value.user.has_totp) {
manageTwoFactorModal.value.show()
@@ -890,7 +891,6 @@ async function showTwoFactorModal() {
twoFactorSecret.value = null
twoFactorFlow.value = null
backupCodes.value = []
manageTwoFactorModal.value.show()
startLoading()
try {
@@ -908,11 +908,22 @@ async function showTwoFactorModal() {
})
}
stopLoading()
manageTwoFactorModal.value.show()
}
const twoFactorIncorrect = ref(false)
const twoFactorCode = ref(null)
const twoFactorCode = ref('')
const backupCodes = ref([])
watch(twoFactorStep, async (step) => {
if (step !== 1) {
return
}
await nextTick()
twoFactorCodeInput.value?.focus()
})
async function verifyTwoFactorCode() {
startLoading()
try {
+3 -4
View File
@@ -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))
}
}
+4 -7
View File
@@ -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))
}
}
+3 -4
View File
@@ -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))
}
}
+8 -7
View File
@@ -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
}
+3 -4
View File
@@ -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))
}
}
+3 -4
View File
@@ -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))
}
}
+3 -4
View File
@@ -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))
}
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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,
@@ -16,14 +16,11 @@ const CHASE_THE_SKIES_SKIN_PACK_SECTION: &str = "Chase the Skies";
const THE_COPPER_AGE_SKIN_PACK_SECTION: &str = "The Copper Age";
const MOUNTS_OF_MAYHEM_SKIN_PACK_SECTION: &str = "Mounts of Mayhem";
const TINY_TAKEOVER_SKIN_PACK_SECTION: &str = "Tiny Takeover";
const CHAOS_CUBED_SKIN_PACK_SECTION: &str = "Chaos Cubed";
/// A list of bundled Minecraft skins to make available to the user.
pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
//
// The Mojang skins below are available in the vanilla Minecraft launcher, and were found
// by reverse engineering the behavior of the Minecraft launcher. The textures are
// publicly available at `https://textures.minecraft.net/texture/<texture_key>`.
//
// These skins have `local-` prefix in their texture_key as they dont exist on Mojang's API.
vec![Skin {
texture_key: Arc::from("local-cb3974d16e88a6bd1fe91b0bd58aa5a89abc01f66aed0efc732b84091a523fc9"),
name: Some(Arc::from("Mr. Pack - Asexual")),
@@ -132,6 +129,11 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
source: SkinSource::Default,
is_equipped: false,
},
//
// The Mojang skins below are available in the vanilla Minecraft launcher, and were found
// by reverse engineering the behavior of the Minecraft launcher. The textures are
// publicly available at `https://textures.minecraft.net/texture/<texture_key>`.
//
Skin {
texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"),
name: Some(Arc::from("Alex")),
@@ -782,5 +784,43 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
}]
},
// Chaos Cubed skin pack
Skin {
texture_key: Arc::from("fa4d0a00cfabcad04659b991176e2b7872c661d54e85808bc12aa59e10dde326"),
name: Some(Arc::from("Brawler")),
section: Some(Arc::from(CHAOS_CUBED_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAbFBMVEUAAADOzs7///+FKypkHSGmPDl0JCXCWlPbxZ7t3rs3Nlbz7nhZFhbd3WW7h0txIyXYqE7TvExwIiWWNDLWlnxmaFqPZU/MsIv5/eFAOzDWb1Ls85Dw96zPhFz0+8Pm5ua/v78pKEkbGjwTECkB9t8tAAAAAXRSTlMAQObYZgAAA2JJREFUeNrtluGaoiAUhkHAUtOsZptxKii9/3vc73Agjdqyp7/7jgd0itcjAiEifWTgP5Gw3iqwXYuUVDAwdwIVmCFYgoeCtt59Itg1TdOa14Jl4E5gTN2ajwQtUpghKAKPMqjN7gPBzgB1L+hfsFYRFgTW6/WWhsV6lqCpiLpp6opovEBriigol1fKW4HWaC9A7SUCwKCJzQaFCO2LwjvKokgMSLSpxQ114zOgt6K3gttDkJmiMBkEtwatyZC25wzakEHph15ZgBIDkQ0Dx6CBqsSESmlPA7xgWIICfH1RuQT4ZxRs6jo8eoSu681m07btpt0IHvzFBBIMgLMQc+iBlPIsAnmuFAcjAicgz849FGTZ+IEK3Av+nBy++CoDCApwL0B7ZJCd/5FBJkVAsyC/EwCXOfl0LmildBAoOs/z1Yri9HtifvlERC4JucGdtSJMjgOXVOUnGKYxFUyW0h4CfBuF1maBig4f9iezNvuxsZ48AhTRABULFgvEjYBv/BsTSDKIAshSQRwRY8Fl2oksiJ2IgiIcHLSakJT1SkSGhNWtgC4RK51JmSHOHpxPHuEmgwt9ezUR5LhUeI3LyDdAJSJKG6A0o9SdAI1JMs40LyhuO1HrawY6vEYKHFGlXUa48AjOTTqRuocyiK8R/aQWC3SYwUGDSMOTnyVBpQPns4jITGaAPvPmHG0VBBSG36QP5yiBJIN3ORyOVszCGNzeiJSjEHMFXKRYOD4THA+HtwTp81PgQG3tK8NisUg6EAmEOFIt3mUvRIc4Hjk68S4dDgS9x4N9IuCBxcHzL5xb9KA9IlCnnVlZW42CXsq+zMpelnR2PX8yZlS1tx3PGWOMlLGdr6/n4glV1VV3c4NBHc7Ff96jB7T2pPuCmZOKBbQCTX/WcelmT2vOAIwbC94XzFtYWECjId0XzFra+gTeDxBc01zgFYFrkXJJoHbT4NnI8bAvrr+yF9APw7gnCDWvB/uOwz4WhOZgwG1xjClQByK6DvFMAAWnwPuBseB1kcL6GFeEVMAMcT8Q9wcixa8Dnd1XyhBqSIj7gbg/EA/BgpBmEPdOvBu48i1eoQllADJDer59EZgjGAit43vgRwibA+fkeYZA8+qouBOd420BIJMTr6Ae41Uxk3RHD28OHmbwF7FfmVsbvSBAAAAAAElFTkSuQmCC"
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("bfa251327ce1fc617dc90879dbfb77dabf151381d5d40f261c7f16e2d147d942"),
name: Some(Arc::from("Builder")),
section: Some(Arc::from(CHAOS_CUBED_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAnFBMVEUAAAC7h0vDlUzTvEzYqE7w96rq6ZH8/cj0+8NmP0RxTE7Zp0JAJCQzGhp/VFTo4pL09Z7GlC/z1oDCrFrXvWbd3WVAOzDcyoyPZU/e4JDs85CXYhXYv2CoeWX///+FKypkHSGWS0Lz7ngrtXoqpnIol2koYEckeVcmiGDGb2+qVVPCWlNWMiaASiEnExIaCwpeOyxNKR+RXiynczg4pncGAAAAAXRSTlMAQObYZgAAA6JJREFUeNrNlu16oyoQgEGDmNo9bb5Me7ofbaqimBiB+7+3M4NgXE1Omic/dl8KjLbzOnloEOI5OpSDjOCcLZeMczLi6wLDVitm7hAkUMF9Ag6C5A6B+aqgtUwFbyDg9wgMf3vj5rpgv2+B/X4i4DxZwDBNvAJfr9br9Yrz73yRsO+c28s1Y2u2SGC4KmDL1XK5XjG2YIxzGFbrJdxi0ZJzxpZWoBR2j70+ESWLJFkxwhkHGCdslcCtKMIxSSIv2Csn2o8FEWMsikiPu4ETzr3g+DNo2+DncSwgDQFgQFzYNDjjhBfHplXNUfnlw7g9Nn0jjwSAAXHh4zc7f3vEC/ibVh0bXLwfP3ABj41qm5OB4B/D4MAQMnEGD17AA/G5yq+/Ung9rGD6EYiroK/MAGVZCuKQsgCkDBzEUQP6oDUZYwylQhTFQKDUGUFV1bXRFwT5oIK6E9RTQYUVmPOCPC9PpYr9XtRnBcYczCgZoYhGpJR1LVEgawgAAcDNqq4Aewt/MxIECMwgQD5LIcpPaSmzT2iQUwGY/vHxcUZgEBR8Zn3LB3FWW5xAyrMCreHH5Ya7XY6GPn6Y//Ng2/zBtpGA9gL/9CzcnSqB+CmOn7E/x/HT0/Pz03kBDHnWtfd8l/VxtstUGAS2B0GInXgCGgAo0CAIgi6n/FXmoc//leVh2apQYVeuDyugFLIdRmTQMB+agIYxjm2rWugdEBMPpVgA1RZwlQW0d4GjbeLdjoUaQTwt4tUQFOJ8Oxy054AQD/4LdwghyjyfWx4AmAaxcbh8TTylyBHIL4QADbPMIYexYawNrhGMAEZkSByR+7hfwP6kIEb+JQ4Wk1t52b6mL70gSsmtpNttmt4j2CInwe3rEaVphGm94K9lNuo3Qymhw357BeFmttnMgmAGPQxvL6Lb+Sj1LyIyIIOtl/xponj8vsTN5bdzgT7oLwvwkxaFECdBVeE+RC7DxgKsICcOyK8MVHCToMzz3wUH8/8VxNFrHDNMRgygLVL6N3tVA/KS4CV93b6kIAgsXmAMvstlZ0A+Ln8ncV+ICNEGmAgcUl4UbC1TgZ6PDgaXBJHdF7yAngT+TNCfC66htRV49OBIoDD+miBAKGC0Ow60riu3HWyghxhPBYgxbi217l/bXVejbYGeF1AswFUwwu4HsDHMNuFsFoYBGeMe6FHDYwHSFdd/REMssCFkTiBE2ZEjpT1YGAc6yDVEIVyyNQkNGFR04+QL9R9m6Quz/GRY4QAAAABJRU5ErkJggg=="
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
Skin {
texture_key: Arc::from("2d89c01ed54a6fa08d534c07200ccddda426183e642d17cef648d90716f4aa92"),
name: Some(Arc::from("Rancher")),
section: Some(Arc::from(CHAOS_CUBED_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAw1BMVEUAAADs85Dcyoze4JDt+sbz97b3+tDq6ZFAOzBSRDdkTj6PZU/n26Xizpnk16TZt4TQoXDXtILIjl69gli2d1DHmGzMpXnSsIHQrX7BjmXOqXzaxJPi1KHdyZfZtIvs5rPewZXgwJbu6rbizZ/jz6Hs4rDjy57n1abWs4np3azl1qXLm3bPo3zdxJfcwpVqt4b7/uzn1qfSmn/TrIT6/eTgtJAmiGAqpnLWpIG+eVrMknFjuIEol2kkeVf09Z7w96z0+8MoxpQlAAAAAXRSTlMAQObYZgAABHJJREFUeNqllgt7mkoQhlkuahJE5KIiqLGxibm1pz3nKKC4+/9/Vb+BhZDNQ63pJ8ss6LzOzl61WrY9nI6Gtu0k8XQeO46mSEgxdmKnE2OaKmfshp47tpNZFE7CSfIBII30/AgYhn7gee5wET5D0aILcKosjKKR6wHgj17/JcC318sBYy/wfXf0+kzaXg4YIgJqwtP25fn5Zft4McCZIXfhzFl9275st3eri5OIbpxTN65Wj5vN42r1m26EGGs7jkZDxybAlKyDZ4DoPT2LM9Lsseu57sy2J9Et+t+2Z64XYjzY49D18VyH3mW1hev5vhcN49vlerm8HQ4jzwuQzKGL1360qH/IFcdTDZh7fhD40WgaBet1MJmOArxAd05dn8bFXHHg8vlUW23uBsvlcjKd//N9vf7+33Qa4TEYj+YzjAsvnNfddlK68WZABvc4DBB7tIhv11AcL6L1ehmEizj0EUkYNwDFDq7J4J5MtrcP93dJ8nD/8+f9Q5Lc3d/fbSeOMwlnYTRzpAPrslqSPG02X7+sVl++/vhR2c3mKUkcJ55P48RBpwsh0PEwJ7LyGe8Hg3o8XEE6pA4azssb06RMKE+LTFP0EVCUElzXDUPXeQPomWaWFbmm6hoyjDYggwohDB0IvRVBDxF0AN5HkEOIAG3ggrUAZQSpEv611FUpAdWAtkySJQvU/vebm6uB1NW16PVETxTHI/xRh2ePJMjTMEwU6z2gCkASKAITAIjyL3omACDgnWFRlqpCkHYPIIIWwDLBIJlcmJYQlkXJF0hmI4I0gGOlohQqcBAWCJZpMWH2AQABIF3RG+BQA1AI0LcEIxcLAKsE9HHR0Njtijc1gMPxAO3hCvf9/iAMAPp9Cx8m+oYA0ECd05dH/Ha32++pdDaBG30ADHzgbBiA9A1cnNz3iJbcqd4ATOoceSMjcLEq2YyhAEZ553ma/g+laZ5nGUZJA6Dfwg/CHXWkW/BqCAOkMybrR9KhzlgrgqpTyrtO4rhkyhmrCokXpQ4oVb7fAIp4KSEYZ1LllOZ5lmdpWpQtyAo0QRWHg3ZGAtK6hEl/HkBXlxgYfwdg/CyAUxM+qyqVotHlAFycOLJ8CoDCqLB3CdeVSodYBwBuBqvGK8YUKt1JFhDdGW5nxkyzcX5Wzcb5J6rC4qwO7zPZZRxF2ssBXDoyVKnUrWgn4Gz3kKO0ojsvHTkpW87rDHxyrKuTSd3uTSjDWqKsC13TWQKwzr3b1rHlN4Bej3cvKM12/waAPwBp/nsAZ+8AahPSdgSCq4Du84KpSJTiUMe6MCiFM4Pc9dWNR05rLm0H4KoJYaBuPDSVa2eqK+uBBFRnhhKgbjzkVK8NVIcbfcdoJSCjy022VqErosUARXSuC8WxBhxKgNQOKkjaOZEXnRMq7Y+73eEAWLOxd88FOR/UJtTuYJWIc7ORq8e5LJcnA3k20FSJypkxadXjnDwWHOqDQdeK1BT1OCePBVLHJolYHgbNmtguuqKM2lCkaZajkn886v8CMs39Yh2w0KoAAAAASUVORK5CYII="
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
},
]
});
@@ -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
}
@@ -106,6 +106,7 @@ pub(crate) async fn create_instance(
tx.commit().await?;
crate::state::instances::watcher::watch_instance_folder(
&instance.id,
&instance.path,
&state.file_watcher,
&state.directories,
@@ -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,
+137 -126
View File
@@ -7,15 +7,20 @@ use crate::state::{
use crate::worlds::WorldType;
use notify::{RecommendedWatcher, RecursiveMode};
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
use std::time::Duration;
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::{RwLock, mpsc::channel};
use super::adapters::sqlite::instance_rows;
pub type FileWatcher = RwLock<Debouncer<RecommendedWatcher>>;
pub struct FileWatcher {
watcher: RwLock<Debouncer<RecommendedWatcher>>,
instance_ids: Arc<RwLock<HashMap<String, String>>>,
}
pub async fn init_watcher() -> crate::Result<FileWatcher> {
let (tx, mut rx) = channel(1);
let instance_ids = Arc::new(RwLock::new(HashMap::new()));
let event_instance_ids = instance_ids.clone();
let file_watcher = new_debouncer(
Duration::from_secs_f32(1.0),
@@ -32,88 +37,85 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
match res {
Ok(events) => {
let instance_ids = event_instance_ids.read().await;
let mut visited_instances = Vec::new();
events.iter().for_each(|e| {
let mut instance_path = None;
for e in &events {
let mut instance_path = None;
let mut found = false;
for component in e.path.components() {
if found {
instance_path = Some(component.as_os_str());
break;
}
let mut found = false;
for component in e.path.components() {
if found {
instance_path = Some(component.as_os_str());
break;
}
if component.as_os_str()
== crate::state::dirs::INSTANCES_FOLDER_NAME
{
found = true;
}
}
if component.as_os_str()
== crate::state::dirs::INSTANCES_FOLDER_NAME
{
found = true;
}
}
if let Some(instance_path) = instance_path {
let instance_path_str =
instance_path.to_string_lossy().to_string();
let first_file_name = e
.path
.components()
.skip_while(|x| x.as_os_str() != instance_path)
.nth(1)
.map(|x| x.as_os_str());
if first_file_name
.as_ref()
.is_some_and(|x| *x == "crash-reports")
&& e.path
.extension()
.as_ref()
.is_some_and(|x| *x == "txt")
{
crash_task(instance_path_str);
} else if !visited_instances.contains(&instance_path)
{
let event = if first_file_name
.as_ref()
.is_some_and(|x| *x == "servers.dat")
{
Some(InstancePayloadType::ServersUpdated)
} else if first_file_name.as_ref().is_some_and(|x| {
*x == "saves"
&& e.path
.file_name()
.as_ref()
.is_some_and(|x| *x == "level.dat")
}) {
tracing::info!(
"World updated: {}",
e.path.display()
);
let world = e
.path
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
if !e.path.is_file() {
let instance_path_str = instance_path_str.clone();
let world = world.clone();
tokio::spawn(async move {
if let Ok(state) = State::get().await {
let instance_id = sqlx::query_scalar!(
"
SELECT id
FROM instances
WHERE path = ?
",
instance_path_str,
)
.fetch_optional(&state.pool)
.await;
let Ok(Some(instance_id)) = instance_id else {
return;
};
if let Err(e) = attached_world_data::AttachedWorldData::remove_for_world(
if let Some(instance_path) = instance_path {
let instance_path_str =
instance_path.to_string_lossy().to_string();
let Some(instance_id) =
instance_ids.get(&instance_path_str).cloned()
else {
continue;
};
let first_file_name = e
.path
.components()
.skip_while(|x| x.as_os_str() != instance_path)
.nth(1)
.map(|x| x.as_os_str());
if first_file_name
.as_ref()
.is_some_and(|x| *x == "crash-reports")
&& e.path
.extension()
.as_ref()
.is_some_and(|x| *x == "txt")
{
crash_task(instance_id);
} else if !visited_instances.contains(&instance_id)
{
let event = if first_file_name
.as_ref()
.is_some_and(|x| *x == "servers.dat")
{
Some(InstancePayloadType::ServersUpdated)
} else if first_file_name.as_ref().is_some_and(
|x| {
*x == "saves"
&& e.path
.file_name()
.as_ref()
.is_some_and(|x| {
*x == "level.dat"
})
},
) {
tracing::info!(
"World updated: {}",
e.path.display()
);
let world = e
.path
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
if !e.path.is_file() {
let instance_id = instance_id.clone();
let world = world.clone();
tokio::spawn(async move {
if let Ok(state) = State::get().await
&& let Err(e) = attached_world_data::AttachedWorldData::remove_for_world(
&instance_id,
WorldType::Singleplayer,
&world,
@@ -121,68 +123,69 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
).await {
tracing::warn!("Failed to remove AttachedWorldData for '{world}': {e}")
}
}
});
}
Some(InstancePayloadType::WorldUpdated { world })
} else if first_file_name
.as_ref()
.is_none_or(|x| *x != "saves")
{
Some(InstancePayloadType::Synced)
} else {
None
};
if let Some(event) = event {
tokio::spawn(async move {
let _ = emit_instance(
&instance_path_str,
event,
)
.await;
});
visited_instances.push(instance_path);
}
}
}
});
});
}
Some(InstancePayloadType::WorldUpdated {
world,
})
} else if first_file_name
.as_ref()
.is_none_or(|x| *x != "saves")
{
Some(InstancePayloadType::Synced)
} else {
None
};
if let Some(event) = event {
let emit_instance_id = instance_id.clone();
tokio::spawn(async move {
let _ = emit_instance(
&emit_instance_id,
event,
)
.await;
});
visited_instances.push(instance_id);
}
}
}
}
}
Err(error) => tracing::warn!("Unable to watch file: {error}"),
}
}
});
Ok(RwLock::new(file_watcher))
Ok(FileWatcher {
watcher: RwLock::new(file_watcher),
instance_ids,
})
}
pub(crate) async fn watch_instances_init(
watcher: &FileWatcher,
dirs: &DirectoryInfo,
pool: &sqlx::SqlitePool,
) {
let Ok(mut instances_dir) = tokio::fs::read_dir(dirs.instances_dir()).await
else {
let Ok(instances) = instance_rows::list_instances(pool).await else {
return;
};
while let Ok(Some(instance_dir)) = instances_dir.next_entry().await {
let file_name = instance_dir.file_name();
let file_name = file_name.to_string_lossy();
if file_name.starts_with(".DS_Store") {
continue;
}
watch_instance_folder(&file_name, watcher, dirs).await;
for instance in instances {
watch_instance_folder(&instance.id, &instance.path, watcher, dirs)
.await;
}
}
pub(crate) async fn watch_instance_folder(
instance_id: &str,
instance_path: &str,
watcher: &FileWatcher,
dirs: &DirectoryInfo,
) {
let instance_path = dirs.instances_dir().join(instance_path);
let full_instance_path = dirs.instances_dir().join(instance_path);
let Ok(metadata) = tokio::fs::metadata(&instance_path).await else {
let Ok(metadata) = tokio::fs::metadata(&full_instance_path).await else {
return;
};
@@ -195,7 +198,7 @@ pub(crate) async fn watch_instance_folder(
.map(|x| x.get_folder())
.chain(["crash-reports", "saves"])
{
let full_path = instance_path.join(sub_path);
let full_path = full_instance_path.join(sub_path);
let meta = tokio::fs::symlink_metadata(&full_path).await;
let exists = meta.is_ok();
@@ -215,10 +218,11 @@ pub(crate) async fn watch_instance_folder(
to_watch.push(full_path);
}
let mut watcher = watcher.write().await;
let mut debouncer = watcher.watcher.write().await;
for full_path in &to_watch {
if let Err(e) =
watcher.watcher().watch(full_path, RecursiveMode::Recursive)
if let Err(e) = debouncer
.watcher()
.watch(full_path, RecursiveMode::Recursive)
{
tracing::error!(
"Failed to watch directory for watcher {full_path:?}: {e}"
@@ -227,22 +231,29 @@ pub(crate) async fn watch_instance_folder(
}
}
if let Err(e) = watcher
if let Err(e) = debouncer
.watcher()
.watch(&instance_path, RecursiveMode::NonRecursive)
.watch(&full_instance_path, RecursiveMode::NonRecursive)
{
tracing::error!(
"Failed to watch root instance directory for watcher {instance_path:?}: {e}"
"Failed to watch root instance directory for watcher {full_instance_path:?}: {e}"
);
}
watcher
.instance_ids
.write()
.await
.insert(instance_path.to_string(), instance_id.to_string());
}
fn crash_task(path: String) {
fn crash_task(instance_id: String) {
tokio::task::spawn(async move {
let res = async {
let state = State::get().await?;
let Some(instance) =
instance_rows::get_instance_by_path(&path, &state.pool).await?
instance_rows::get_instance_by_id(&instance_id, &state.pool)
.await?
else {
return Ok(());
};
+1
View File
@@ -107,6 +107,7 @@ impl State {
instances::watcher::watch_instances_init(
&state.file_watcher,
&state.directories,
&state.pool,
)
.await;
+2 -1
View File
@@ -13,6 +13,7 @@ const MAX_MODERN_STATUS_PACKET_LENGTH: usize =
MAX_MINECRAFT_STATUS_STRING_LENGTH + 4;
const MAX_LEGACY_STATUS_UTF16_LENGTH: usize =
MAX_MINECRAFT_STATUS_STRING_LENGTH;
const SERVER_STATUS_TIMEOUT: Duration = Duration::from_secs(5);
/// Ensures the length of a packet as stated by a server is not longer than a
/// hard-coded limit.
@@ -87,7 +88,7 @@ pub async fn get_server_status(
protocol => modern::status(address, original_address, protocol.map(|v| v.version)).await,
}
} => res,
_ = tokio::time::sleep(Duration::from_secs(30)) => Err(ErrorKind::OtherError(
_ = tokio::time::sleep(SERVER_STATUS_TIMEOUT) => Err(ErrorKind::OtherError(
format!("Ping of {}:{} timed out", original_address.0, original_address.1)
).into())
}
+49
View File
@@ -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',

Some files were not shown because too many files have changed in this diff Show More