Compare commits

...
Author SHA1 Message Date
François-X. T. ab86c47661 chore: fmt 2026-07-03 12:55:43 -04:00
François-X. T. 000b7039b7 fix(labrinth): include SharedInstanceInvite notifications for ws 2026-07-03 12:42:18 -04:00
Prospector 0dac5684af changelog 2026-07-03 08:16:29 -07:00
coolbotandGitHub ca0b8d5ca4 add excessive languages warning nag (#6587) 2026-07-03 12:12:07 +00:00
coolbotandGitHub b6651efe52 Moderation Fix: Handle more license cases (#6590)
Apply correct behavior in edge cases
2026-07-02 17:26:10 +00:00
aecsocketandGitHub eb28121c0a fix: scan logic file whitelist + rescan route (#6585)
* explicitly only scan jar/zip/disabled files for attributions

* add route to force scan file

* prepare
2026-07-02 16:33:16 +00:00
SychicandGitHub a5ebc85356 refactor(labrinth): combine logic for external notification endpoints (#6578)
* refactor(labrinth): combine logic for external notification endpoints

also introduce new notification that doesn't send an email

* style(labrinth): derive default

* feat(labrinth): return more detailed failure information for external
notifications
2026-07-02 15:44:47 +00:00
Prospector c530671be1 fix collection widget on mobile 2026-07-01 18:09:12 -07:00
Prospector bb6d8a6fc4 changelog 2026-07-01 17:44:29 -07:00
ProspectorandGitHub 256e8f946a chore: pride month fundraiser conclusion (#6588)
* pride end blog post

* Fix drago link

* prepr oops
2026-07-01 17:43:44 -07:00
Calum H.andGitHub 7e5590b8b9 fix: improve observability during fetch failure (#6555)
* fix: improve observability during fetch failure

+ remove analytics log spam

* fix: rev comments
2026-07-01 15:58:20 +00:00
SychicandGitHub d20f05fb04 feat(labrinth): instance share notification (#6581) 2026-07-01 14:21:56 +00:00
Truman GaoandGitHub a00a7971d1 feat: add tooltip for unknown dependents (#6572) 2026-07-01 05:21:42 +00:00
aecsocketandGitHub 26e98b7155 fix: change Tiltify campaign errors to quieter log level v2 (#6576)
import
2026-06-30 21:36:47 +02:00
aecsocketandGitHub 77b06f679e fix: change Tiltify campaign errors to quieter log level (#6575) 2026-06-30 21:34:37 +02:00
Truman GaoandGitHub 50e9472dd9 fix: cannot set owner for creating projects (#6573) 2026-06-30 17:00:10 +00: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
113 changed files with 2487 additions and 598 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>
@@ -122,9 +122,6 @@
"app.export-modal.modpack-name-placeholder": {
"message": "إسم حزمة التعديل"
},
"app.export-modal.select-files-label": {
"message": "أختيار الملفات التي سيتم تصديرها"
},
"app.export-modal.version-number-label": {
"message": "رقم الإصدار"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportovat modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Zahrnout \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Název modpacku"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Název modpacku"
},
"app.export-modal.select-files-label": {
"message": "Nastavte, které soubory mají být do tohoto exportu zahrnuty"
},
"app.export-modal.version-number-label": {
"message": "Číslo verze"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "Eksporter modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludere \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Navn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack navn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurer hvilke filer inkludere i denne eksport"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschliessen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesem Export miteinbezogen werden"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschließen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpackname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpackname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesen Export enthalten sind"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Export modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Include \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Name"
"message": "Modpack name"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack name"
},
"app.export-modal.select-files-label": {
"message": "Configure which files are included in this export"
},
"app.export-modal.version-number-label": {
"message": "Version number"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos incluir en esta exportación"
},
"app.export-modal.version-number-label": {
"message": "Número de la versión"
},
@@ -182,18 +182,12 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos se incluirán en esta instancia"
},
"app.export-modal.version-number-label": {
"message": "Número de versión"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Vie modipaketti"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sisällytä \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modipaketin nimi"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modipaketin nimi"
},
"app.export-modal.select-files-label": {
"message": "Muuta mitkä tiedostot sisältyvät tähän vientiin"
},
"app.export-modal.version-number-label": {
"message": "Versio numero"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "Iluwas ang modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Salihin ang \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Pangalan ng Modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Pangalan ng modpack"
},
"app.export-modal.select-files-label": {
"message": "Isaayos kung anong mga file ang isasali sa pagluwas"
},
"app.export-modal.version-number-label": {
"message": "Numero ng bersiyon"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exporter le modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inclure « {file} » ?"
},
"app.export-modal.modpack-name-label": {
"message": "Nom du modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nom du modpack"
},
"app.export-modal.select-files-label": {
"message": "Configurez quels fichiers sont inclus dans cette exportation"
},
"app.export-modal.version-number-label": {
"message": "Numéro de version"
},
@@ -176,18 +176,12 @@
"app.export-modal.header": {
"message": "Modcsomag exportálása"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Tartalmaz „{file}” fájlokat?"
},
"app.export-modal.modpack-name-label": {
"message": "A modcsomag neve"
},
"app.export-modal.modpack-name-placeholder": {
"message": "A modcsomag neve"
},
"app.export-modal.select-files-label": {
"message": "Állítsd be, hogy mely fájlok kerüljenek bele az exportba"
},
"app.export-modal.version-number-label": {
"message": "Verziószám"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Ekspor paket mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Paket Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama paket mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan berkas mana yang disertakan dalam pengeksporan ini"
},
"app.export-modal.version-number-label": {
"message": "Nomor versi"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Esporta pacchetto"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Includere \"{file}\" nell'esportazione?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome del pacchetto"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome del pacchetto"
},
"app.export-modal.select-files-label": {
"message": "Seleziona i file da includere in questa esportazione"
},
"app.export-modal.version-number-label": {
"message": "Numero di versione"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "모드팩 내보내기"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\"(을)를 포함할까요?"
},
"app.export-modal.modpack-name-label": {
"message": "모드팩 이름"
},
"app.export-modal.modpack-name-placeholder": {
"message": "모드팩 이름"
},
"app.export-modal.select-files-label": {
"message": "내보내기에 어느 파일이 포함될지 구성"
},
"app.export-modal.version-number-label": {
"message": "버전 구분"
},
@@ -155,18 +155,12 @@
"app.export-modal.header": {
"message": "Eksport pek mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Pek Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama pek mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan fail yang disertakan dalam eksport ini"
},
"app.export-modal.version-number-label": {
"message": "Nombor versi"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Exporteer modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Betrek \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Naam"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack naam"
},
"app.export-modal.select-files-label": {
"message": "Configureer welke bestanden bij deze export zijn betrokken"
},
"app.export-modal.version-number-label": {
"message": "Versie nummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Eksportuj paczkę modów"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Dołączyć \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nazwa paczki modów"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nazwa paczki modów"
},
"app.export-modal.select-files-label": {
"message": "Skonfiguruje jakie pliki zostaną załączone w tym eksporcie"
},
"app.export-modal.version-number-label": {
"message": "Numer wersji"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportar pacote de mods"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome do pacote de mods"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome do pacote de mods"
},
"app.export-modal.select-files-label": {
"message": "Configure quais arquivos serão incluídos na exportação"
},
"app.export-modal.version-number-label": {
"message": "Número da versão"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Экспорт сборки"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Добавить «{file}» в экспорт?"
},
"app.export-modal.modpack-name-label": {
"message": "Название сборки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Название сборки"
},
"app.export-modal.select-files-label": {
"message": "Выберите файлы для экспорта"
},
"app.export-modal.version-number-label": {
"message": "Номер версии"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportera modpaket"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludera \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketets namn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketets namn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurera vilka filer som inkluderas i denna export"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "ส่งออกแพ็กม็อด"
},
"app.export-modal.include-file-accessibility-label": {
"message": "รวมถึง \"{file}\" ใช่หรือไม่"
},
"app.export-modal.modpack-name-label": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.modpack-name-placeholder": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.select-files-label": {
"message": "กำหนดไฟล์ที่ต้องการจะส่งออก"
},
"app.export-modal.version-number-label": {
"message": "หมายเลขเวอร์ชัน"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Mod paketini dışa aktar"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" dahil mi?"
},
"app.export-modal.modpack-name-label": {
"message": "Mod Paketi Adı"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Mod paketi adı"
},
"app.export-modal.select-files-label": {
"message": "Bu dışa aktarmaya hangi dosyaların dahil edileceğini yapılandırma"
},
"app.export-modal.version-number-label": {
"message": "Sürüm numarası"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Експортувати збірку"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Уключити «{file}»?"
},
"app.export-modal.modpack-name-label": {
"message": "Назва збірки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Назва збірки"
},
"app.export-modal.select-files-label": {
"message": "Змініть файли, які додані до експорту"
},
"app.export-modal.version-number-label": {
"message": "Номер версії"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Xuất modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Bao gồm \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Tên modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Tên modpack"
},
"app.export-modal.select-files-label": {
"message": "Cấu hình các tệp nào được bao gồm trong quá trình xuất phiên bản này"
},
"app.export-modal.version-number-label": {
"message": "Phiên bản"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "导出整合包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "包含 \"{file}\""
},
"app.export-modal.modpack-name-label": {
"message": "整合包名称"
},
"app.export-modal.modpack-name-placeholder": {
"message": "整合包名称"
},
"app.export-modal.select-files-label": {
"message": "配置此导出中包含哪些文件"
},
"app.export-modal.version-number-label": {
"message": "版本号"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "匯出模組包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "要包含「{file}」嗎?"
},
"app.export-modal.modpack-name-label": {
"message": "模組包名稱"
},
"app.export-modal.modpack-name-placeholder": {
"message": "模組包名稱"
},
"app.export-modal.select-files-label": {
"message": "設定要包含在此匯出檔案中的檔案"
},
"app.export-modal.version-number-label": {
"message": "版本號碼"
},
@@ -676,7 +676,7 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
break
case 'copy_path': {
if (instance.value) {
const fullPath = await get_full_path(instance.value?.path)
const fullPath = await get_full_path(instance.value.id)
await navigator.clipboard.writeText(fullPath)
}
break
@@ -1443,7 +1443,7 @@ onMounted(() => {
props.instance &&
event.instance_id === props.instance.id &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing' &&
props.instance.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
+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,
)
@@ -538,7 +566,7 @@ export function buildChartDatasets(
? isNoDependentAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.noDependentTooltip)
: isUnknownAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.unknown)
? formatMessage(analyticsMessages.unknownDependentTooltip)
: formatDependentProjectDatasetTooltip(
versionName,
dependentProjectName,
@@ -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,
)
@@ -92,6 +92,11 @@ export const analyticsMessages = defineMessages({
id: 'analytics.value.no-dependent-tooltip',
defaultMessage: 'Downloaded for reasons other than being a dependency',
},
unknownDependentTooltip: {
id: 'analytics.value.unknown-dependent-tooltip',
defaultMessage:
"There's no metadata to determine which dependent project this download attributes to.",
},
other: {
id: 'analytics.value.other',
defaultMessage: 'Other',
@@ -515,7 +515,7 @@ function getDependentProjectTooltip(row: AnalyticsTableRow) {
return formatMessage(analyticsMessages.noDependentTooltip)
}
if (isUnknownAnalyticsBreakdownValue(row.breakdownValues.dependent_project_download)) {
return formatMessage(analyticsMessages.unknown)
return formatMessage(analyticsMessages.unknownDependentTooltip)
}
const dependencyProjectIds = new Set(row.dependentOnProjectIds)
@@ -73,6 +73,7 @@
:options="[userOption, ...ownerOptions]"
searchable
:disabled="hasHitLimit"
select-search-text-on-focus
show-icon-in-selected
/>
<span>{{ formatMessage(messages.ownerDescription) }}</span>
@@ -129,7 +130,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { PlusIcon, XIcon } from '@modrinth/assets'
import { OrganizationIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
@@ -362,7 +363,13 @@ async function fetchOrganizations() {
}),
),
)
: undefined,
: markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () => h(OrganizationIcon, { class: 'size-5' }),
}),
),
),
}))
if (props.organizationId) owner.value = props.organizationId
} catch (err) {
@@ -395,6 +402,7 @@ async function createProject() {
server_side: 'required',
license_id: 'LicenseRef-Unknown',
is_draft: true,
organization_id: owner.value !== 'self' ? owner.value : undefined,
}
formData.append('data', JSON.stringify(projectData))
+48
View File
@@ -5,6 +5,10 @@
<div class="pointer-events-none absolute inset-0 z-[-1]">
<div id="absolute-background-teleport" class="relative"></div>
</div>
<div
class="pride-backdrop pointer-events-none absolute inset-0 z-[-1]"
:class="{ shown: showPrideBackdrop }"
></div>
<div class="pointer-events-none absolute inset-0 z-50">
<div
class="over-the-top-random-animation"
@@ -801,6 +805,7 @@ import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { provideCurrentProjectId } from '~/providers/current-project.ts'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
import { hasActiveMidas } from '~/utils/user-membership.ts'
@@ -867,6 +872,32 @@ const showTinMismatchBanner = computed(() => {
return !!auth.value.user && status === 'tin-mismatch'
})
const PRIDE_COLLECTION_ID = 'M4c3ITvd'
const PRIDE_ARTICLE_SLUGS = ['pride-campaign-2025', 'pride-campaign-2026', 'proud-of-you-2026']
const PRIDE_CACHE_TIME = 1000 * 60 * 60 * 24
const { data: prideCollection } = useQuery({
queryKey: computed(() => ['collection', PRIDE_COLLECTION_ID]),
queryFn: () => client.labrinth.collections.get(PRIDE_COLLECTION_ID),
staleTime: PRIDE_CACHE_TIME,
gcTime: PRIDE_CACHE_TIME,
})
const prideProjectIds = computed(() => new Set(prideCollection.value?.projects ?? []))
const currentProjectId = ref()
provideCurrentProjectId(currentProjectId)
const showPrideBackdrop = computed(() => {
if (PRIDE_ARTICLE_SLUGS.includes(route.params.slug)) {
return true
}
if (route.params.collection === PRIDE_COLLECTION_ID) {
return true
}
return !!currentProjectId.value && prideProjectIds.value.has(currentProjectId.value)
})
const basePopoutId = useId()
async function fetchIntercomToken() {
@@ -1695,4 +1726,21 @@ const { cycle: changeTheme } = useTheme()
transform: translateY(0);
}
}
.pride-backdrop {
background-image: linear-gradient(to right, #c20732, #f57203, #ffd632, #21ca8b, #2f9ff2, #e420fc);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0) 80%);
height: 30rem;
opacity: 0;
transition: opacity 1s ease;
}
.pride-backdrop.shown {
opacity: 0.08;
}
.light-mode .pride-backdrop.shown,
.light .pride-backdrop.shown {
opacity: 0.15;
}
</style>
@@ -518,6 +518,9 @@
"analytics.value.unknown": {
"message": "Unknown"
},
"analytics.value.unknown-dependent-tooltip": {
"message": "There's no metadata to determine which dependent project this download attributes to."
},
"analytics.value.unmonetized": {
"message": "Unmonetized"
},
+12 -1
View File
@@ -1114,7 +1114,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
import { nextTick, onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } from 'vue'
import { navigateTo } from '#app'
import Accordion from '~/components/ui/Accordion.vue'
@@ -1130,6 +1130,7 @@ import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { injectCurrentProjectId } from '~/providers/current-project.ts'
import {
loadChecklistOpenState,
saveChecklistOpenState,
@@ -1705,6 +1706,16 @@ const project = computed(() => {
// Use actual project ID for dependent queries (ensures cache consistency)
const projectId = computed(() => projectRaw.value?.id)
const sharedProjectId = injectCurrentProjectId(null)
if (sharedProjectId) {
watchEffect(() => {
sharedProjectId.value = projectId.value ?? undefined
})
onScopeDispose(() => {
sharedProjectId.value = undefined
})
}
// V3 Project
const {
data: projectV3,
@@ -954,5 +954,9 @@ function openEditModal(event) {
color: var(--color-brand);
font-weight: 600;
}
a:hover {
text-decoration: underline;
}
}
</style>
@@ -0,0 +1,9 @@
import { createContext } from '@modrinth/ui'
import type { Ref } from 'vue'
/**
* Informs the default layout of the current project ID, if any, because it can't be gleaned from the route which may be a slug
*/
export const [injectCurrentProjectId, provideCurrentProjectId] = createContext<
Ref<string | undefined>
>('root', 'currentProjectId')
Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "Proud of you all",
"summary": "Over 2,400 of you came together to raise more than $40,000 for charity this Pride month!",
"thumbnail": "https://modrinth.com/news/article/proud-of-you-2026/thumbnail.webp",
"date": "2026-07-02T01:00:00.000Z",
"link": "https://modrinth.com/news/article/proud-of-you-2026"
},
{
"title": "Improving Modpack review delays",
"summary": "Reducing the back-and-forth needed to get your modpack approved.",
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tselect\n\t\t\tf.url,\n\t\t\tf.version_id as \"version_id: DBVersionId\",\n\t\t\tv.mod_id as \"project_id: DBProjectId\"\n\t\tfrom files f\n\t\tinner join versions v on v.id = f.version_id\n\t\twhere f.id = $1\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "url",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "version_id: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "project_id: DBProjectId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "17f415f1140df5b3dd42a161c0f77b2475edb8041bc2b9701d51cf9cbfd69ba1"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tdelete from attributions_exemptions\n\t\twhere version_id = $1\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "5ed1e727901573ee92683f206f3321fa4e192c75caa5a62a2790b5136e9a7dde"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO payouts_values_notifications (date_available, user_id, notified)\n VALUES ($1, $2, FALSE)\n ON CONFLICT (date_available, user_id) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Int8"
]
},
"nullable": []
},
"hash": "6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n select\n (\n select count(*)\n from project_attribution_groups\n where project_id = $1\n ) as \"groups!\",\n (\n select count(*)\n from project_attribution_files paf\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where pag.project_id = $1\n ) as \"files!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "groups!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "files!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null,
null
]
},
"hash": "686994a5bfc061b4c6e1ca594fae9d5d3fb18974de6605a650f666d440dfe684"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)\n VALUES ($1, NULL, $2, NOW(), $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Numeric",
"Timestamptz"
]
},
"nullable": []
},
"hash": "69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41"
}
@@ -0,0 +1,6 @@
INSERT INTO notifications_types
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
VALUES ('shared_instance_invite', 1, FALSE, TRUE);
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
VALUES (NULL, 'email', 'shared_instance_invite', FALSE);
@@ -73,6 +73,10 @@ pub enum LegacyNotificationBody {
invited_by: UserId,
role: String,
},
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -177,6 +181,9 @@ impl LegacyNotification {
NotificationBody::ServerInvite { .. } => {
Some("server_invite".to_string())
}
NotificationBody::SharedInstanceInvite { .. } => {
Some("shared_instance_invite".to_string())
}
NotificationBody::StatusChange { .. } => {
Some("status_change".to_string())
}
@@ -294,6 +301,13 @@ impl LegacyNotification {
invited_by,
role,
},
NotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
} => LegacyNotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
},
NotificationBody::StatusChange {
project_id,
old_status,
@@ -36,6 +36,7 @@ pub enum NotificationType {
TeamInvite,
OrganizationInvite,
ServerInvite,
SharedInstanceInvite,
StatusChange,
ModeratorMessage,
LegacyMarkdown,
@@ -71,6 +72,7 @@ impl NotificationType {
NotificationType::TeamInvite => "team_invite",
NotificationType::OrganizationInvite => "organization_invite",
NotificationType::ServerInvite => "server_invite",
NotificationType::SharedInstanceInvite => "shared_instance_invite",
NotificationType::StatusChange => "status_change",
NotificationType::ModeratorMessage => "moderator_message",
NotificationType::LegacyMarkdown => "legacy_markdown",
@@ -112,6 +114,7 @@ impl NotificationType {
"team_invite" => NotificationType::TeamInvite,
"organization_invite" => NotificationType::OrganizationInvite,
"server_invite" => NotificationType::ServerInvite,
"shared_instance_invite" => NotificationType::SharedInstanceInvite,
"status_change" => NotificationType::StatusChange,
"moderator_message" => NotificationType::ModeratorMessage,
"legacy_markdown" => NotificationType::LegacyMarkdown,
@@ -173,6 +176,10 @@ pub enum NotificationBody {
invited_by: UserId,
role: String,
},
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -288,6 +295,9 @@ impl NotificationBody {
NotificationBody::ServerInvite { .. } => {
NotificationType::ServerInvite
}
NotificationBody::SharedInstanceInvite { .. } => {
NotificationType::SharedInstanceInvite
}
NotificationBody::StatusChange { .. } => {
NotificationType::StatusChange
}
@@ -470,6 +480,32 @@ impl From<DBNotification> for Notification {
},
],
),
NotificationBody::SharedInstanceInvite {
shared_instance_name,
..
} => (
"You have been invited to a shared instance!".to_string(),
format!(
"An invite has been sent for you to join {shared_instance_name}"
),
"#".to_string(),
vec![
NotificationAction {
name: "Accept".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
NotificationAction {
name: "Deny".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
],
),
NotificationBody::StatusChange {
old_status,
new_status,
@@ -772,6 +772,7 @@ async fn collect_template_variables(
}
NotificationBody::ProjectUpdate { .. }
| NotificationBody::SharedInstanceInvite { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::LegacyMarkdown { .. }
| NotificationBody::Unknown => Ok(EmailTemplate::Static(map)),
+73 -7
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use chrono::Utc;
use eyre::{Result, eyre};
use hex::ToHex;
use serde::Serialize;
use sha1::Digest;
use tokio::task::{spawn, spawn_blocking};
use tracing::{Instrument, info, info_span, warn};
@@ -42,6 +43,16 @@ struct PendingFileScan {
project_id: DBProjectId,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct FileScanSummary {
/// Number of attribution groups newly created by the scan.
pub new_attribution_groups: u64,
/// Number of attribution files newly created by the scan.
pub new_attribution_files: u64,
/// Override file paths found and scanned in the file archive.
pub scanned_file_names: Vec<String>,
}
/// Attribution enforcement is version/project-scoped, not file-hash-scoped.
///
/// Versions or projects listed in `attributions_exemptions` predate this
@@ -277,7 +288,7 @@ pub async fn scan_file(
project_id: DBProjectId,
file_id: DBFileId,
file_url: &str,
) -> Result<()> {
) -> Result<FileScanSummary> {
let result =
scan_file_inner(txn, redis, file_host, project_id, file_id, file_url)
.await;
@@ -296,7 +307,7 @@ async fn scan_file_inner(
project_id: DBProjectId,
file_id: DBFileId,
file_url: &str,
) -> Result<()> {
) -> Result<FileScanSummary> {
let overrides =
extract_override_files_from_storage(file_host, file_id, file_url)
.await
@@ -304,7 +315,16 @@ async fn scan_file_inner(
eyre!("extracting overrides for file {file_id:?}")
})?;
let scanned_file_names =
overrides.iter().map(|file| file.path.clone()).collect();
let mut summary = FileScanSummary {
scanned_file_names,
..Default::default()
};
if !overrides.is_empty() {
let before = count_project_attributions(project_id, txn).await?;
let resolved = resolve_overrides(&overrides, redis, txn)
.await
.wrap_err_with(|| {
@@ -318,15 +338,58 @@ async fn scan_file_inner(
.wrap_err_with(|| {
eyre!("persisting attribution results for file {file_id:?}")
})?;
let after = count_project_attributions(project_id, txn).await?;
summary.new_attribution_groups =
after.groups.saturating_sub(before.groups);
summary.new_attribution_files =
after.files.saturating_sub(before.files);
log_marked_override_projects(&resolved);
}
Ok(())
Ok(summary)
}
fn file_scan_result(result: &Result<()>) -> FileScanResult<'static> {
struct ProjectAttributionCounts {
groups: u64,
files: u64,
}
async fn count_project_attributions(
project_id: DBProjectId,
txn: &mut PgTransaction<'_>,
) -> Result<ProjectAttributionCounts> {
let row = sqlx::query!(
r#"
select
(
select count(*)
from project_attribution_groups
where project_id = $1
) as "groups!",
(
select count(*)
from project_attribution_files paf
inner join project_attribution_groups pag on pag.id = paf.group_id
where pag.project_id = $1
) as "files!"
"#,
project_id as DBProjectId,
)
.fetch_one(&mut *txn)
.await
.wrap_err("counting project attributions")?;
Ok(ProjectAttributionCounts {
groups: row.groups as u64,
files: row.files as u64,
})
}
fn file_scan_result<T>(result: &Result<T>) -> FileScanResult<'static> {
match result {
Ok(()) => Ok(()),
Ok(_) => Ok(()),
Err(err) => Err(ApiError {
error: "internal_error",
description: format!("{err:#}"),
@@ -480,6 +543,7 @@ const OVERRIDE_PREFIXES: &[&str] = &[
];
fn should_scan(name: &str) -> bool {
let name = name.to_lowercase();
let should_skip = name.starts_with("mods/.connector/")
|| name.starts_with(".sable/natives/")
|| name.starts_with("local/crash_assistant/")
@@ -491,8 +555,10 @@ fn should_scan(name: &str) -> bool {
|| name.starts_with("essential/")
|| name.ends_with(".rpo")
|| name.ends_with(".txt");
let is_archive = name.contains(".jar") || name.contains(".zip");
let is_archive = name.ends_with(".jar")
|| name.ends_with(".zip")
|| name.ends_with(".jar.disabled")
|| name.ends_with(".zip.disabled");
is_archive && !should_skip
}
@@ -3,17 +3,18 @@ use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
use crate::auth::get_user_from_headers;
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database::PgPool;
use crate::database::models::{
DBOrganization, DBTeamMember, DBVersion,
DBFileId, DBOrganization, DBTeamMember, DBVersion,
ids::{
DBAttributionGroupId, DBProjectId, DBVersionId,
generate_attribution_group_id,
},
};
use crate::database::redis::RedisPool;
use crate::models::ids::{ProjectId, VersionId};
use crate::file_hosting::FileHost;
use crate::models::ids::{FileId, ProjectId, VersionId};
use crate::models::pats::Scopes;
use crate::models::projects::{
AttributionModerationStatusKind, AttributionResolution,
@@ -21,6 +22,7 @@ use crate::models::projects::{
};
use crate::models::teams::ProjectPermissions;
use crate::models::users::User;
use crate::queue::file_scan::{FileScanSummary, scan_file};
use crate::queue::moderation::ApprovalType;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
@@ -30,6 +32,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(list)
.service(update_group)
.service(scan)
.service(force_scan_file)
.service(assign)
.service(split);
}
@@ -201,6 +204,80 @@ async fn scan(
}))
}
#[utoipa::path]
#[post("/file/{file_id}/scan")]
async fn force_scan_file(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
file_host: web::Data<dyn FileHost>,
path: web::Path<FileId>,
) -> Result<web::Json<FileScanSummary>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let file_id: DBFileId = path.into_inner().into();
let file = sqlx::query!(
r#"
select
f.url,
f.version_id as "version_id: DBVersionId",
v.mod_id as "project_id: DBProjectId"
from files f
inner join versions v on v.id = f.version_id
where f.id = $1
"#,
file_id as DBFileId,
)
.fetch_optional(pool.as_ref())
.await
.wrap_internal_err("failed to fetch attribution scan file")?
.ok_or(ApiError::NotFound)?;
let mut transaction = pool.begin().await.wrap_internal_err(
"failed to begin attribution file scan transaction",
)?;
sqlx::query!(
r#"
delete from attributions_exemptions
where version_id = $1
"#,
file.version_id as DBVersionId,
)
.execute(&mut transaction)
.await
.wrap_internal_err("failed to remove attribution scan exemption")?;
let scan_summary = scan_file(
&mut transaction,
redis.as_ref(),
&**file_host,
file.project_id,
file_id,
&file.url,
)
.await
.wrap_internal_err("failed to scan file for attributions")?;
transaction.commit().await.wrap_internal_err(
"failed to commit attribution file scan transaction",
)?;
DBVersion::clear_cache_ids(&[file.version_id], redis.as_ref())
.await
.wrap_internal_err("failed to clear version cache")?;
Ok(web::Json(scan_summary))
}
#[utoipa::path]
#[get("/{project_id}")]
async fn list(
@@ -8,7 +8,7 @@ use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashSet;
use tracing::{info, warn};
use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::{
@@ -388,7 +388,7 @@ pub async fn pride_26(
Ok(campaign_info) => Ok(web::Json(campaign_info)),
Err(error) => {
if let Some(cached) = cached {
warn!(
debug!(
"Failed to refresh campaign info from Tiltify: {error:?}"
);
Ok(web::Json(cached))
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::ids::{DBNotificationId, DBUserId};
@@ -5,26 +7,26 @@ use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::models::notifications::NotificationDeliveryStatus;
use crate::models::users::Role;
use crate::models::v3::notifications::{
Notification, NotificationBody, NotificationDeliveryStatus,
};
use crate::models::v3::notifications::{Notification, NotificationBody};
use crate::models::v3::pats::Scopes;
use crate::queue::email::EmailQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::external_notifications::EmailFailure::{
FailedToSend, MailboxNotFound, UserNotFound,
};
use crate::routes::internal::statuses::broadcast_friends_message;
use crate::sync::friends::RedisFriendsMessage;
use crate::util::guards::external_notification_key_guard;
use actix_web::http::StatusCode;
use actix_web::web;
use actix_web::{
CustomizeResponder, HttpRequest, HttpResponse, Responder, delete, post,
};
use actix_web::{HttpRequest, HttpResponse, delete, post};
use ariadne::ids::UserId;
use eyre::eyre;
use lettre::message::Mailbox;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(create)
@@ -33,66 +35,72 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(send_custom_email);
}
#[derive(Deserialize, PartialEq, Default)]
enum EmailStrategy {
#[default]
Async,
Sync,
None,
}
#[derive(Deserialize)]
struct CreateNotification {
pub body: NotificationBody,
pub user_ids: Vec<UserId>,
#[serde(default)]
pub email: EmailStrategy,
}
#[derive(thiserror::Error, Debug, Serialize)]
#[serde(tag = "type", content = "data")]
enum EmailFailure {
#[error("user not found")]
UserNotFound,
#[error("mailbox not found")]
MailboxNotFound,
#[error("failed to send: {0:?}")]
FailedToSend(NotificationDeliveryStatus),
#[error("api error: {0}")]
ApiError(
#[serde(serialize_with = "serialize_api_error")]
#[from]
crate::routes::ApiError,
),
}
fn serialize_api_error<S>(
error: &ApiError,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
error.as_api_error().serialize(serializer)
}
#[post("external_notifications", guard = "external_notification_key_guard")]
pub async fn create(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>,
) -> Result<HttpResponse, ApiError> {
let CreateNotification { body, user_ids } =
create_notification.into_inner();
let user_ids = user_ids
.into_iter()
.map(|x| DBUserId(x.0 as i64))
.collect::<Vec<_>>();
let mut txn = pool.begin().await?;
if !DBUser::exists_many(&user_ids, &mut txn).await? {
return Err(ApiError::InvalidInput(
"One of the specified users do not exist.".to_owned(),
));
}
let notification_ids = NotificationBuilder { body }
.insert_many(user_ids, &mut txn, &redis)
.await?;
let notifications =
get_site_exposed_notifications(&notification_ids, &mut txn).await?;
txn.commit().await?;
broadcast_notifications(&redis, notifications).await;
Ok(HttpResponse::Accepted().finish())
) -> Result<(web::Json<HashMap<UserId, EmailFailure>>, StatusCode), ApiError> {
create_impl(pool, redis, email_queue, create_notification.into_inner())
.await
}
/// Inserts notifications for all users and tries to send emails immediately.
///
/// Responds with the user IDs that could not be emailed:
/// - `200` if every recipient was emailed (empty list)
/// - `207` if some recipients could not be emailed (list of failed IDs)
#[post(
"external_notifications/email-sync",
guard = "external_notification_key_guard"
)]
pub async fn create_email_sync(
async fn create_impl(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>,
) -> Result<CustomizeResponder<web::Json<Vec<UserId>>>, ApiError> {
let CreateNotification { body, user_ids } =
create_notification.into_inner();
data: CreateNotification,
) -> Result<(web::Json<HashMap<UserId, EmailFailure>>, StatusCode), ApiError> {
let CreateNotification {
body,
user_ids,
email: email_strategy,
} = data;
let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>();
let user_ids = raw_user_ids
.iter()
.map(|x| DBUserId(*x))
@@ -129,9 +137,21 @@ pub async fn create_email_sync(
.filter(|id| !already_notified.contains(id))
.collect::<Vec<_>>();
let notification_ids = NotificationBuilder { body: body.clone() }
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
.await?;
let notification_builder = NotificationBuilder { body: body.clone() };
let notification_ids = if email_strategy == EmailStrategy::Async {
notification_builder
.insert_many(notification_user_ids, &mut txn, &redis)
.await?
} else {
notification_builder
.insert_many_without_delivery(
notification_user_ids,
&mut txn,
&redis,
)
.await?
};
let notifications =
get_site_exposed_notifications(&notification_ids, &mut txn).await?;
@@ -140,42 +160,98 @@ pub async fn create_email_sync(
broadcast_notifications(&redis, notifications).await;
let mut email_txn = pool.begin().await?;
if email_strategy == EmailStrategy::Sync {
let mut email_txn = pool.begin().await?;
let mut failed = Vec::new();
for user_id in &user_ids {
let Some(user) =
DBUser::get_id(*user_id, &mut email_txn, &redis).await?
else {
failed.push(UserId(user_id.0 as u64));
continue;
};
let mut failed = HashMap::new();
let users = DBUser::get_many_ids(&user_ids, &mut email_txn, &redis)
.await?
.into_iter()
.map(|user| (user.id, user))
.collect::<HashMap<_, _>>();
let delivered = match user
.email
.and_then(|email| email.parse::<Mailbox>().ok())
{
Some(mailbox) => {
email_queue
.send_one(&mut email_txn, body.clone(), *user_id, mailbox)
.await?
== NotificationDeliveryStatus::Delivered
}
None => false,
};
for db_user_id in &user_ids {
let user_id = UserId(db_user_id.0 as u64);
let Some(user) = users.get(db_user_id) else {
failed.insert(user_id, UserNotFound);
continue;
};
if !delivered {
failed.push(UserId(user_id.0 as u64));
let Some(mailbox) = user
.email
.as_ref()
.and_then(|email| email.parse::<Mailbox>().ok())
else {
failed.insert(user_id, MailboxNotFound);
continue;
};
match email_queue
.send_one(&mut email_txn, body.clone(), *db_user_id, mailbox)
.await
{
Ok(status) => {
if status != NotificationDeliveryStatus::Delivered {
failed.insert(user_id, FailedToSend(status));
}
}
Err(error) => {
if matches!(
error,
ApiError::SqlxDatabase(_) | ApiError::Database(_)
) {
return Err(error);
};
failed.insert(user_id, error.into());
}
};
}
email_txn.commit().await?;
let status = if failed
.values()
.any(|x| matches!(x, EmailFailure::ApiError(_)))
{
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::OK
};
return Ok((web::Json(failed), status));
}
let status = if failed.is_empty() {
StatusCode::OK
} else {
StatusCode::MULTI_STATUS
};
Ok((web::Json(HashMap::new()), StatusCode::ACCEPTED))
}
Ok(web::Json(failed).customize().with_status(status))
/// Inserts notifications for all users and tries to send emails immediately.
///
/// Responds with the user IDs that could not be emailed and a reason why
#[post(
"external_notifications/email-sync",
guard = "external_notification_key_guard"
)]
pub async fn create_email_sync(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
data: web::Json<CreateNotification>,
) -> Result<(web::Json<Vec<UserId>>, StatusCode), ApiError> {
let data = data.into_inner();
create_impl(
pool,
redis,
email_queue,
CreateNotification {
body: data.body,
user_ids: data.user_ids,
email: EmailStrategy::Sync,
},
)
.await
.map(|(res, code)| {
(web::Json(res.into_inner().into_keys().collect()), code)
})
}
#[derive(Deserialize)]
+18 -16
View File
@@ -130,23 +130,25 @@ pub async fn ws_init(
)?)
.await;
let unread_server_invites = DBNotification::get_many_user_exposed_on_site(
user_id.into(),
&**pool,
&redis,
)
.await?
.into_iter()
.filter(|notification| {
!notification.read
&& matches!(
&notification.body,
NotificationBody::ServerInvite { .. }
)
})
.map(Notification::from);
let unread_launcher_invites =
DBNotification::get_many_user_exposed_on_site(
user_id.into(),
&**pool,
&redis,
)
.await?
.into_iter()
.filter(|notification| {
!notification.read
&& matches!(
&notification.body,
NotificationBody::ServerInvite { .. }
| NotificationBody::SharedInstanceInvite { .. }
)
})
.map(Notification::from);
for notification in unread_server_invites {
for notification in unread_launcher_invites {
let _ = session.text(serde_json::to_string(&notification)?).await;
}
+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,
@@ -619,6 +619,83 @@ pub(crate) async fn rename_instance_file(
) -> crate::Result<Option<InstanceFile>> {
let enabled = i64::from(enabled);
let modified_at = Utc::now().timestamp();
let mut tx = pool.begin().await?;
let source_id = sqlx::query_scalar!(
"
SELECT id
FROM instance_files
WHERE instance_id = ? AND relative_path = ?
",
instance_id,
old_relative_path,
)
.fetch_optional(&mut *tx)
.await?;
let target_id = sqlx::query_scalar!(
"
SELECT id
FROM instance_files
WHERE instance_id = ? AND relative_path = ?
",
instance_id,
new_relative_path,
)
.fetch_optional(&mut *tx)
.await?;
if let (Some(source_id), Some(target_id)) =
(source_id.as_deref(), target_id.as_deref())
&& source_id != target_id
{
sqlx::query!(
"
DELETE FROM instance_content_entries
WHERE id IN (
SELECT target_entry.id
FROM instance_content_entries target_entry
WHERE target_entry.instance_id = ?
AND target_entry.file_id = ?
AND EXISTS (
SELECT 1
FROM instance_content_entries source_entry
WHERE source_entry.instance_id = target_entry.instance_id
AND source_entry.content_set_id = target_entry.content_set_id
AND source_entry.file_id = ?
)
)
",
instance_id,
target_id,
source_id,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"
UPDATE instance_content_entries
SET file_id = ?, modified_at = ?
WHERE instance_id = ? AND file_id = ?
",
source_id,
modified_at,
instance_id,
target_id,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"
DELETE FROM instance_files
WHERE id = ?
",
target_id,
)
.execute(&mut *tx)
.await?;
}
sqlx::query!(
"
@@ -638,9 +715,11 @@ pub(crate) async fn rename_instance_file(
instance_id,
old_relative_path,
)
.execute(pool)
.execute(&mut *tx)
.await?;
tx.commit().await?;
get_instance_file_by_relative_path(instance_id, new_relative_path, pool)
.await
}
@@ -209,29 +209,18 @@ pub(crate) async fn list_content(
.await?;
let imported_modpack_scope = is_imported_modpack_scope(&link);
let linked_modpack_source_kind = linked_modpack_source_kind(&link);
let mut failed_modpack_identifier_lookup = false;
let modpack_ids = if imported_modpack_scope {
None
} else {
match linked_modpack_ids(&link) {
Some((_, version_id)) => match get_modpack_identifiers(
&version_id,
&resolved.content_set,
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(ids) => Some(ids),
Err(err) => {
tracing::warn!(
"Failed to fetch modpack identifiers: {}",
err
);
failed_modpack_identifier_lookup = true;
None
}
},
Some((_, version_id)) => {
get_cached_modpack_identifiers(
&version_id,
&state.pool,
&state.api_semaphore,
)
.await?
}
None => None,
}
};
@@ -243,10 +232,9 @@ pub(crate) async fn list_content(
}
} else if let Some(ids) = modpack_ids.as_ref() {
ContentFilter::ExcludeModpack(ids)
} else if failed_modpack_identifier_lookup {
} else if let Some(source_kind) = linked_modpack_source_kind {
ContentFilter::ExcludeSourceKind {
source_kind: linked_modpack_source_kind
.unwrap_or(ContentSourceKind::ModrinthModpack),
source_kind,
exclude_untracked: true,
}
} else {
@@ -1182,6 +1170,28 @@ impl ModpackIdentifiers {
}
}
async fn get_cached_modpack_identifiers(
version_id: &str,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<ModpackIdentifiers>> {
let Some(cached) =
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
.await?
else {
return Ok(None);
};
if cached.project_ids.is_empty() {
return Ok(None);
}
Ok(Some(ModpackIdentifiers {
hashes: cached.file_hashes.into_iter().collect(),
project_ids: cached.project_ids.into_iter().collect(),
}))
}
async fn get_modpack_identifiers(
version_id: &str,
content_set: &ContentSet,
+43 -25
View File
@@ -5,6 +5,7 @@ use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
use chrono::{DateTime, TimeDelta, Utc};
use eyre::{Context, eyre};
use parking_lot::Mutex;
use rand::Rng;
use reqwest::Method;
@@ -445,7 +446,7 @@ async fn fetch_advanced_with_client_and_progress(
}
if let Some((name, value)) = &download_meta_header {
tracing::info!("Sending download analytics: {value}");
tracing::debug!("Sending download analytics: {value}");
req = req.header(name.as_str(), value.as_str());
}
@@ -472,38 +473,55 @@ async fn fetch_advanced_with_client_and_progress(
return Err(backup_error.into());
}
let bytes = if loading_bar.is_some() || progress.is_some() {
let bytes: eyre::Result<Bytes> = if loading_bar.is_some()
|| progress.is_some()
{
let length = resp.content_length();
if let Some(total_size) = length {
use futures::StreamExt;
let mut stream = resp.bytes_stream();
let mut bytes = Vec::new();
let mut downloaded = 0_u64;
while let Some(item) = stream.next().await {
let chunk = item.or(Err(ErrorKind::NoValueFor(
"fetch bytes".to_string(),
)))?;
downloaded += chunk.len() as u64;
bytes.append(&mut chunk.to_vec());
if let Some((bar, total)) = &loading_bar {
emit_loading(
bar,
(chunk.len() as f64 / total_size as f64)
* total,
None,
)?;
}
if let Some(progress) = progress.as_mut() {
progress(downloaded, total_size).await?;
}
}
Ok(bytes::Bytes::from(bytes))
async {
let mut bytes = Vec::new();
let mut downloaded = 0_u64;
while let Some(item) = stream.next().await {
let chunk = item.wrap_err_with(|| {
eyre!(
"failed to read response body from {url}"
)
})?;
downloaded += chunk.len() as u64;
bytes.extend_from_slice(&chunk);
if let Some((bar, total)) = &loading_bar {
emit_loading(
bar,
(chunk.len() as f64
/ total_size as f64)
* total,
None,
)?;
}
if let Some(progress) = progress.as_mut() {
progress(downloaded, total_size).await?;
}
}
Ok(Bytes::from(bytes))
}
.await
} else {
resp.bytes().await
resp.bytes().await.wrap_err_with(|| {
eyre!("failed to read response body from {url}")
})
}
} else {
resp.bytes().await
resp.bytes().await.wrap_err_with(|| {
eyre!("failed to read response body from {url}")
})
};
if let Ok(bytes) = bytes {
@@ -0,0 +1,66 @@
---
title: Proud of you all
summary: Over 2,400 of you came together to raise more than $40,000 for charity this Pride month!
date: 2026-07-01T18:00:00-07:00
authors: ['vNcGR3Fd', 'bOHH0P9Z', '2cqK8Q5p', 'VmRGDAOP', 'nHj8InZT', 'K86yjB3D']
---
Earlier this month, we announced that we would be raising money for Rainbow Railroad throughout Pride Month.
When we created our campaign, we wanted to set a realistic goal, so we started at $5,000.
You hit it within 3 hours, before Pride Month had even begun. So we raised the goal to $10,000 - and less than an hour into June 1st (UTC), this was achieved.
We were completely astounded and, honestly, lost for words.
## Your Accomplishments
![A Minecraft screenshot of a theater stage with a large Modrinth logo, featuring members of the Modrinth team. Mr. Pack is at the very front speaking into a microphone while a staff member leads a Ribbit Pride parade and another leads a bee Pride parade.](./stage.webp)
Together, you helped fund work that supports LGBTQIA+ people facing danger around the world. That can mean emergency support, temporary safe housing, travel documents, crisis response, local partner networks, and relocation pathways when staying where they are is no longer safe.
![A screenshot showing the top 10 donors for Modrinths 2026 Pride fundraiser.](./donors.webp)
Thank you to all of our top donors!
By the end of the campaign, you raised over $40,000 for Rainbow Railroad. We thank all 2,478 who donated, no matter how big or small - you should all be incredibly proud.
Modrinths total made up nearly half of Rainbow Railroads entire total for their Solidarity in Pride event on Tiltify of $80,000. Absolutely incredible!
### Mr. Pack
Historically, Modrinth has had a Frog mascot for many years. It has appeared across our branding, social posts, Discord emotes, seasonal artwork, and community memes, but it never really had a proper name or identity.
Mr. Pack was created as a small thank-you to everyone who supported the fundraiser: a new mascot skin for the Modrinth App, with several Pride variants available to donors who donated more than $5. Mr. Pack is the 5th most popular skin on NameMC for June 2026 - over 4,000 players are using him and his variants!
![All of Mr. Packs variants (MLM, Genderfluid, Lesbian, Transgender, Asexual, Intersex, Pride, Bisexual, and Nonbinary) posed and lined up in a row.](./pride-skins.webp)
_Special thanks to [Drago](https://x.com/iDragolyte) for creating Mr. Packs updated design and pride variants._
### Badges and Modrinth+
<img src="./pride-badge.webp" alt="Modrinths Pride badge over a rainbow background." width="150" height="150" style="float: right;margin-left: 1rem;" />
Everyone who donated to the fundraiser, regardless of the amount, received the limited Pride 2026 badge on their Modrinth profile as a thank you for supporting Rainbow Railroad. We also gave eligible donors a free month of Modrinth+ as an additional thank you - if you have this badge, you are a _very_ cool person.
### Pride Collection
This year, we launched our Pride Collection, an assortment of queer content hosted on Modrinth and submitted to us by our amazing community of creators.
<div id="pride-collection-widget"></div>
Thank you to everyone who submitted their projects; we look forward to adding even more content to this collection in the years to come!
## Pride Is All Year
![A Minecraft screenshot of a theater featuring Modrinth team and community members in the audience. There are also many pride flags along the sides of the room.](./audience.webp)
Rainbow Railroads work continues all year round, and LGBTQIA+ people around the world continue to face danger simply for being who they are. This fundraiser was one way for us to turn Pride into practical support, but it cannot be the only time we show up. Pride month may be over, but the fight continues.
Modrinth is home to queer creators, players, developers, moderators, artists, and community members. They are part of what makes this platform what it is, and supporting them should not be limited to one month of the year.
If youd still like to support this cause, you can donate to Rainbow Railroad at any time - and we encourage you to do so.
Thank you for what has been an incredible Pride month, and we look forward to doing even more next year. 💚
+64
View File
@@ -10,6 +10,70 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-03T15:16:21+00:00`,
product: 'web',
body: `## Fixed
- Fixed project license URL being required when not using a custom license.`,
},
{
date: `2026-07-02T00:44:14+00:00`,
product: 'web',
body: `## Changed
- Added pride backdrop to the Pride collection, projects featured in the Pride collection, and the pride blog posts.
## Fixed
- Fixed issue where you cannot create a project directly with an organization as the owner.`,
},
{
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',
+2
View File
@@ -30,6 +30,7 @@ import { article as new_site_beta } from "./new_site_beta";
import { article as plugins_resource_packs } from "./plugins_resource_packs";
import { article as pride_campaign_2025 } from "./pride_campaign_2025";
import { article as pride_campaign_2026 } from "./pride_campaign_2026";
import { article as proud_of_you_2026 } from "./proud_of_you_2026";
import { article as redesign } from "./redesign";
import { article as russian_censorship } from "./russian_censorship";
import { article as server_access } from "./server_access";
@@ -74,6 +75,7 @@ export const articles = [
plugins_resource_packs,
pride_campaign_2025,
pride_campaign_2026,
proud_of_you_2026,
redesign,
russian_censorship,
server_access,
@@ -0,0 +1,2 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const html = `<p>Earlier this month, we announced that we would be raising money for Rainbow Railroad throughout Pride Month.</p><p>When we created our campaign, we wanted to set a realistic goal, so we started at $5,000.</p><p>You hit it within 3 hours, before Pride Month had even begun. So we raised the goal to $10,000 - and less than an hour into June 1st (UTC), this was achieved.</p><p>We were completely astounded and, honestly, lost for words.</p><h2>Your Accomplishments</h2><p><img src="/news/article/proud-of-you-2026/stage.webp" alt="A Minecraft screenshot of a theater stage with a large Modrinth logo, featuring members of the Modrinth team. Mr. Pack is at the very front speaking into a microphone while a staff member leads a Ribbit Pride parade and another leads a bee Pride parade."></p><p>Together, you helped fund work that supports LGBTQIA+ people facing danger around the world. That can mean emergency support, temporary safe housing, travel documents, crisis response, local partner networks, and relocation pathways when staying where they are is no longer safe.</p><p><img src="/news/article/proud-of-you-2026/donors.webp" alt="A screenshot showing the top 10 donors for Modrinths 2026 Pride fundraiser."></p><p>Thank you to all of our top donors!</p><p>By the end of the campaign, you raised over $40,000 for Rainbow Railroad. We thank all 2,478 who donated, no matter how big or small - you should all be incredibly proud.</p><p>Modrinths total made up nearly half of Rainbow Railroads entire total for their Solidarity in Pride event on Tiltify of $80,000. Absolutely incredible!</p><h3>Mr. Pack</h3><p>Historically, Modrinth has had a Frog mascot for many years. It has appeared across our branding, social posts, Discord emotes, seasonal artwork, and community memes, but it never really had a proper name or identity.</p><p>Mr. Pack was created as a small thank-you to everyone who supported the fundraiser: a new mascot skin for the Modrinth App, with several Pride variants available to donors who donated more than $5. Mr. Pack is the 5th most popular skin on NameMC for June 2026 - over 4,000 players are using him and his variants!</p><p><img src="/news/article/proud-of-you-2026/pride-skins.webp" alt="All of Mr. Packs variants (MLM, Genderfluid, Lesbian, Transgender, Asexual, Intersex, Pride, Bisexual, and Nonbinary) posed and lined up in a row."></p><p><em>Special thanks to <a href="https://x.com/iDragolyte" rel="noopener nofollow ugc">Drago</a> for creating Mr. Packs updated design and pride variants.</em></p><h3>Badges and Modrinth+</h3><img src="./pride-badge.webp" alt="Modrinths Pride badge over a rainbow background." width="150" height="150" style="float: right;margin-left: 1rem;"><p>Everyone who donated to the fundraiser, regardless of the amount, received the limited Pride 2026 badge on their Modrinth profile as a thank you for supporting Rainbow Railroad. We also gave eligible donors a free month of Modrinth+ as an additional thank you - if you have this badge, you are a <em>very</em> cool person.</p><h3>Pride Collection</h3><p>This year, we launched our Pride Collection, an assortment of queer content hosted on Modrinth and submitted to us by our amazing community of creators.</p><div id="pride-collection-widget"></div><p>Thank you to everyone who submitted their projects; we look forward to adding even more content to this collection in the years to come!</p><h2>Pride Is All Year</h2><p><img src="/news/article/proud-of-you-2026/audience.webp" alt="A Minecraft screenshot of a theater featuring Modrinth team and community members in the audience. There are also many pride flags along the sides of the room."></p><p>Rainbow Railroads work continues all year round, and LGBTQIA+ people around the world continue to face danger simply for being who they are. This fundraiser was one way for us to turn Pride into practical support, but it cannot be the only time we show up. Pride month may be over, but the fight continues.</p><p>Modrinth is home to queer creators, players, developers, moderators, artists, and community members. They are part of what makes this platform what it is, and supporting them should not be limited to one month of the year.</p><p>If youd still like to support this cause, you can donate to Rainbow Railroad at any time - and we encourage you to do so.</p><p>—</p><p>Thank you for what has been an incredible Pride month, and we look forward to doing even more next year. 💚</p>`;
@@ -0,0 +1,12 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const article = {
html: () => import(`./proud_of_you_2026.content`).then(m => m.html),
title: "Proud of you all",
summary: "Over 2,400 of you came together to raise more than $40,000 for charity this Pride month!",
date: "2026-07-02T01:00:00.000Z",
slug: "proud-of-you-2026",
authors: ["vNcGR3Fd","bOHH0P9Z","2cqK8Q5p","VmRGDAOP","nHj8InZT","K86yjB3D"],
unlisted: false,
thumbnail: true,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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