mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
update archiving, move general project settings to v3 (start redesign there), use project id lookups for stable cache keys, advanced in server search
This commit is contained in:
+82
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ArchiveIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ArchivedProjectBanner,
|
||||
defineMessages,
|
||||
SettingsFormGroup,
|
||||
SettingsToggleCard,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import type { NoteDisclosure } from './types'
|
||||
|
||||
const model = defineModel<NoteDisclosure>({ required: true })
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
projectTitle: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.settings.disclosures.archived.title',
|
||||
defaultMessage: 'Archive project',
|
||||
},
|
||||
description1: {
|
||||
id: 'project.settings.disclosures.archived.description.1',
|
||||
defaultMessage:
|
||||
'Mark your project as archived to let users know that you are no longer working on it.',
|
||||
},
|
||||
description2: {
|
||||
id: 'project.settings.disclosures.archived.description.2',
|
||||
defaultMessage:
|
||||
'Archived projects remain discoverable when their visibility is set to Public. If you wish to de-list your project, set its visibility to Unlisted',
|
||||
},
|
||||
noteLabel: {
|
||||
id: 'project.settings.disclosures.archived.note-label',
|
||||
defaultMessage: 'Optionally explain why this project is archived.',
|
||||
},
|
||||
notePlaceholder: {
|
||||
id: 'project.settings.disclosures.archived.note-placeholder',
|
||||
defaultMessage: `e.g. I don't have time to maintain this project anymore, feel free to fork it!`,
|
||||
},
|
||||
bannerPreview: {
|
||||
id: 'project.settings.disclosures.archived.banner-preview',
|
||||
defaultMessage: 'Banner preview',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsToggleCard
|
||||
v-model="model.enabled"
|
||||
:disabled="disabled"
|
||||
:icon="ArchiveIcon"
|
||||
:title="formatMessage(messages.title)"
|
||||
>
|
||||
<p>{{ formatMessage(messages.description1) }}</p>
|
||||
<p>{{ formatMessage(messages.description2) }}</p>
|
||||
<template #expanded>
|
||||
<SettingsFormGroup
|
||||
:title="formatMessage(messages.noteLabel)"
|
||||
title-for="archived-disclosure-note"
|
||||
>
|
||||
<StyledInput
|
||||
id="archived-disclosure-note"
|
||||
v-model="model.note"
|
||||
multiline
|
||||
:rows="3"
|
||||
class="max-w-[40rem]"
|
||||
:disabled="disabled"
|
||||
:placeholder="formatMessage(messages.notePlaceholder)"
|
||||
/>
|
||||
</SettingsFormGroup>
|
||||
<SettingsFormGroup :title="formatMessage(messages.bannerPreview)">
|
||||
<ArchivedProjectBanner :title="projectTitle" :reason="model.note" />
|
||||
</SettingsFormGroup>
|
||||
</template>
|
||||
</SettingsToggleCard>
|
||||
</template>
|
||||
@@ -16,7 +16,11 @@ function findDisclosure<T extends DisclosureType>(
|
||||
return disclosures.find((disclosure): disclosure is DisclosureOf<T> => disclosure.type === type)
|
||||
}
|
||||
|
||||
type NoteDisclosureType = 'advertisements' | 'epilepsy_triggers' | 'system_interactions'
|
||||
type NoteDisclosureType =
|
||||
| 'advertisements'
|
||||
| 'epilepsy_triggers'
|
||||
| 'system_interactions'
|
||||
| 'archived'
|
||||
|
||||
function createNoteModel(
|
||||
disclosures: ProjectDisclosureData[],
|
||||
@@ -61,6 +65,7 @@ export function disclosuresToForm(disclosures: ProjectDisclosureData[]): Disclos
|
||||
},
|
||||
photosensitivity: createNoteModel(disclosures, 'epilepsy_triggers'),
|
||||
systemInteractions: createNoteModel(disclosures, 'system_interactions'),
|
||||
archived: createNoteModel(disclosures, 'archived'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +118,10 @@ export function formToDisclosures(form: DisclosureFormState): ProjectDisclosure[
|
||||
set.push({ type: 'system_interactions', note: form.systemInteractions.note.trim() || null })
|
||||
}
|
||||
|
||||
if (form.archived.enabled) {
|
||||
set.push({ type: 'archived', note: form.archived.note.trim() || null })
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as AdvertisingDisclosureCard } from './AdvertisingDisclosureCard.vue'
|
||||
export { default as AiDisclosureCard } from './AiDisclosureCard.vue'
|
||||
export { default as ArchivedDisclosureCard } from './ArchivedDisclosureCard.vue'
|
||||
export { default as DerivativeDisclosureCard } from './DerivativeDisclosureCard.vue'
|
||||
export * from './form'
|
||||
export { default as PaidFeaturesDisclosureCard } from './PaidFeaturesDisclosureCard.vue'
|
||||
|
||||
@@ -43,4 +43,5 @@ export type DisclosureFormState = {
|
||||
derivative: DerivativeDisclosure
|
||||
photosensitivity: NoteDisclosure
|
||||
systemInteractions: NoteDisclosure
|
||||
archived: NoteDisclosure
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showProjectPageCreateServersTooltip: true,
|
||||
showProjectPageQuickServerButton: false,
|
||||
newProjectGeneralSettings: false,
|
||||
newProjectEnvironmentSettings: true,
|
||||
serverRamAsBytesAlwaysOn: false,
|
||||
archonSentryCapture: false,
|
||||
hideRussiaCensorshipBanner: false,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
const messages = defineMessages({
|
||||
headTitle: {
|
||||
id: 'project.settings.head-title',
|
||||
defaultMessage: '⚙️{section} - {project}',
|
||||
defaultMessage: '⚙️ {section} - {project}',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
import type { AbstractModrinthClient } from '@modrinth/api-client'
|
||||
import type { QueryClient } from '@tanstack/query-core'
|
||||
|
||||
export const STALE_TIME = 1000 * 60 * 5 // 5 minutes
|
||||
export const STALE_TIME_LONG = 1000 * 60 * 10 // 10 minutes
|
||||
|
||||
/** Anything with a canonical project ID and optional slug (search hits, full projects, etc.). */
|
||||
export type ProjectCheckIdentity = {
|
||||
id?: string | null
|
||||
project_id?: string | null
|
||||
slug?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm `['project', 'check', …]` so navigating to a project by slug skips the check network round-trip.
|
||||
* Safe to call with search hits, full project objects, or mixed lists.
|
||||
*/
|
||||
export function warmProjectCheckCaches(
|
||||
queryClient: QueryClient,
|
||||
projects: ProjectCheckIdentity | readonly ProjectCheckIdentity[] | null | undefined,
|
||||
) {
|
||||
const list = projects == null ? [] : Array.isArray(projects) ? projects : [projects]
|
||||
for (const project of list) {
|
||||
const id = project.id ?? project.project_id
|
||||
if (!id) continue
|
||||
queryClient.setQueryData(['project', 'check', id], { id })
|
||||
if (project.slug) {
|
||||
queryClient.setQueryData(['project', 'check', project.slug], { id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project query options.
|
||||
*
|
||||
* `v2` / `v3` / members / etc. must be keyed by the **canonical project ID**.
|
||||
* When you only have a route slug (or unknown id-or-slug), resolve with `check` first.
|
||||
*/
|
||||
export const projectQueryOptions = {
|
||||
/** Resolve a slug or ID to the canonical project ID. */
|
||||
check: (idOrSlug: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', 'check', idOrSlug] as const,
|
||||
queryFn: () => client.labrinth.projects_v2.check(idOrSlug),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
/** @param projectId Canonical project ID (not slug) */
|
||||
v2: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', 'v2', projectId] as const,
|
||||
queryFn: () => client.labrinth.projects_v2.get(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
/** @param projectId Canonical project ID (not slug) */
|
||||
v3: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', 'v3', projectId] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.get(projectId),
|
||||
|
||||
@@ -2486,9 +2486,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Upload venligst en version først for at kunne vælge tags!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} er blevet arkiveret. {title} vil ikke modtage flere opdateringer medmindre forfatteren beslutter sig for at fjerne projektet fra arkivet."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Kopier ID"
|
||||
},
|
||||
|
||||
@@ -3974,9 +3974,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Bitte lade zuerst eine Version hoch, um Tags auswählen zu können!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} wurde archiviert. {title} wird keine weiteren Updates erhalten, es sei denn, der Autor entscheided sich, das Projekt zu Dearchivieren."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "ID kopieren"
|
||||
},
|
||||
|
||||
@@ -3974,9 +3974,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Bitte lade zuerst eine Version hoch, um Tags auswählen zu können!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} wurde archiviert. {title} wird keine weiteren Updates erhalten, es sei denn, der Autor entscheidet sich das Projekt zu dearchivieren."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "ID kopieren"
|
||||
},
|
||||
|
||||
@@ -3761,6 +3761,30 @@
|
||||
"project.settings.back-to-project-page": {
|
||||
"message": "Back to project page"
|
||||
},
|
||||
"project.settings.danger-zone": {
|
||||
"message": "Danger zone"
|
||||
},
|
||||
"project.settings.delete-project.button": {
|
||||
"message": "Delete project"
|
||||
},
|
||||
"project.settings.delete-project.confirmation.description": {
|
||||
"message": "If you proceed, all of this project's information and all of its versions will be immediately deleted from our database. None of it is recoverable later if you change your mind. Consider just setting your project to be Private for a less permanent option."
|
||||
},
|
||||
"project.settings.delete-project.confirmation.proceed-text": {
|
||||
"message": "Permanently delete project"
|
||||
},
|
||||
"project.settings.delete-project.confirmation.title": {
|
||||
"message": "Are you sure you want to delete this project?"
|
||||
},
|
||||
"project.settings.delete-project.description.1": {
|
||||
"message": "Permanently deletes this project from Modrinth. Deleted projects <emphasis>cannot be recovered</emphasis> by Modrinth staff or support."
|
||||
},
|
||||
"project.settings.delete-project.description.2": {
|
||||
"message": "Files uploaded to this project that are actively used in Modpacks hosted on Modrinth may continue to exist."
|
||||
},
|
||||
"project.settings.delete-project.title": {
|
||||
"message": "Delete project"
|
||||
},
|
||||
"project.settings.disclosures.advertising.description.1": {
|
||||
"message": "You must enable this if your project contains advertisements, sponsorships, or promotions of other works."
|
||||
},
|
||||
@@ -3806,6 +3830,24 @@
|
||||
"project.settings.disclosures.ai.types-text": {
|
||||
"message": "Text"
|
||||
},
|
||||
"project.settings.disclosures.archived.banner-preview": {
|
||||
"message": "Banner preview"
|
||||
},
|
||||
"project.settings.disclosures.archived.description.1": {
|
||||
"message": "Mark your project as archived to let users know that you are no longer working on it."
|
||||
},
|
||||
"project.settings.disclosures.archived.description.2": {
|
||||
"message": "Archived projects remain discoverable when their visibility is set to Public. If you wish to de-list your project, set its visibility to Unlisted"
|
||||
},
|
||||
"project.settings.disclosures.archived.note-label": {
|
||||
"message": "Optionally explain why this project is archived."
|
||||
},
|
||||
"project.settings.disclosures.archived.note-placeholder": {
|
||||
"message": "e.g. I don't have time to maintain this project anymore, feel free to fork it!"
|
||||
},
|
||||
"project.settings.disclosures.archived.title": {
|
||||
"message": "Archive project"
|
||||
},
|
||||
"project.settings.disclosures.content-disclosures": {
|
||||
"message": "Content disclosures"
|
||||
},
|
||||
@@ -3969,7 +4011,16 @@
|
||||
"message": "URL"
|
||||
},
|
||||
"project.settings.head-title": {
|
||||
"message": "⚙️{section} - {project}"
|
||||
"message": "⚙️ {section} - {project}"
|
||||
},
|
||||
"project.settings.monetization.description": {
|
||||
"message": "Projects on Modrinth are automatically enrolled in the <rewards-program-link>Rewards Program</rewards-program-link>. If you don't want to (or can't for legal reasons) earn revenue from this project, you can turn it off here."
|
||||
},
|
||||
"project.settings.monetization.disabled-description": {
|
||||
"message": "This project is not eligible for monetization. If you think this is a mistake, please <contact-support-link>contact support</contact-support-link>."
|
||||
},
|
||||
"project.settings.monetization.title": {
|
||||
"message": "Monetization"
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-approved": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published."
|
||||
@@ -4154,9 +4205,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Please upload a version first in order to select tags!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Copy ID"
|
||||
},
|
||||
|
||||
@@ -3974,9 +3974,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Por favor, ¡sube una versión primero para poder seleccionar etiquetas!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} se ha archivado. {title} no recibirá más actualizaciones hasta que el autor decida desarchivar el proyecto."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Copiar ID"
|
||||
},
|
||||
|
||||
@@ -3329,9 +3329,6 @@
|
||||
"project.settings.permissions.sort.status": {
|
||||
"message": "Estado"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} ha sido archivado. {title} no recibirá ninguna futura actualización excepto que el autor decida desarchivar el proyecto."
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "Versiones"
|
||||
},
|
||||
|
||||
@@ -2411,9 +2411,6 @@
|
||||
"project.settings.permissions.sort.status": {
|
||||
"message": "Estado"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "Inarkibo ang {title}. Ang {title} ay hindi na tatanggap ng mga update maliban kung iwawala nila ang pagkaarkibo ng proyekto."
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "Mga bersiyon"
|
||||
},
|
||||
|
||||
@@ -3974,9 +3974,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Merci d'upload une version en premier afin de pouvoir sélectionner des tags !"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} a été archivé. {title} ne recevra plus de mises à jour jusqu'à ce que l'auteur du projet décide de désarchiver le projet."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Copier l'ID"
|
||||
},
|
||||
|
||||
@@ -2021,9 +2021,6 @@
|
||||
"project.settings.general.url.title": {
|
||||
"message": "URL"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "הפרויקט {title} הועבר לארכיון. {title} לא יקבל עדכונים נוספים, אלא אם המחבר יחליט להוציא את הפרויקט מהארכיון."
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "גרסאות"
|
||||
},
|
||||
|
||||
@@ -3680,9 +3680,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "A címkék kiválasztásához előbb tölts fel egy verziót!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "A(z) {title} archiválásra került. A(z) {title} nem kap további frissítéseket, kivéve, ha a fejlesztő úgy dönt, hogy visszavonja a projekt archiválását."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Azonosító másolása"
|
||||
},
|
||||
|
||||
@@ -2414,9 +2414,6 @@
|
||||
"project.settings.general.url.title": {
|
||||
"message": "URL"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} telah diarsip. {title} tidak akan menerima pembaruan di masa mendatang kecuali pembuat memutuskan untuk membuka arsip proyek ini."
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "Versi"
|
||||
},
|
||||
|
||||
@@ -3950,9 +3950,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Carica una vesione prima di selezionare i tag!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} è stato archiviato. {title} non riceverà più aggiornamenti a meno che l'autore decida di rimuoverlo dall'archivio."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Copia ID"
|
||||
},
|
||||
|
||||
@@ -3005,9 +3005,6 @@
|
||||
"project.settings.permissions.sort.status": {
|
||||
"message": "ステータス"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} はアーカイブされました。作者がプロジェクトのアーカイブを解除しない限り、今後 {title} に更新はありません。"
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "バージョン"
|
||||
},
|
||||
|
||||
@@ -3974,9 +3974,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "먼저 버전을 업로드해야 태그를 선택할 수 있습니다!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} 은(는) 보관되었습니다. 작성자가 프로젝트 보관 해제를 결정하지 않는 한 더 이상 업데이트가 제공되지 않습니다."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "ID 복사"
|
||||
},
|
||||
|
||||
@@ -3068,9 +3068,6 @@
|
||||
"project.settings.tags.title": {
|
||||
"message": "Tag"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} telah diarkibkan. {title} tidak akan menerima sebarang kemas kini lanjut melainkan pengarang memutuskan untuk menyaharkibkan projek."
|
||||
},
|
||||
"project.versions.share-option": {
|
||||
"message": "Kongsi"
|
||||
},
|
||||
|
||||
@@ -3971,9 +3971,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Upload eerst een versie om tags te kunnen selecteren!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} is gearchiveerd. {title} zal geen verdere updates krijgen tenzij de auteur het project de-archiveerd."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "ID kopiëren"
|
||||
},
|
||||
|
||||
@@ -2888,9 +2888,6 @@
|
||||
"project.settings.permissions.sort.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} har blitt arkivert. {title} kommer ikke til å få noen nye oppdateringer, hvis ikke forfatteren bestemmer seg for å dearkivere prosjektet."
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "Versjoner"
|
||||
},
|
||||
|
||||
@@ -3899,9 +3899,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Prześlij wersję, by móc wybierać tagi!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} zostało zarchiwizowane. {title} nie będzie otrzymywać żadnych dalszych aktualizacji, chyba że autor zdecyduje się przywrócić projekt."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Kopiuj ID"
|
||||
},
|
||||
|
||||
@@ -3974,9 +3974,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Por favor, envie uma versão primeiro para que as etiquetas possam ser selecionadas!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} foi arquivado. {title} não receberá atualizações a menos que o autor decida desarquivar o projeto."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Copiar ID"
|
||||
},
|
||||
|
||||
@@ -2756,9 +2756,6 @@
|
||||
"project.settings.general.url.title": {
|
||||
"message": "URL"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} foi arquivado. {title} não receberá mais atualizações, a menos que o autor decida desarquivar o projeto."
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "Versões"
|
||||
},
|
||||
|
||||
@@ -1448,9 +1448,6 @@
|
||||
"project.settings.general.url.title": {
|
||||
"message": "URL"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "Proiectul {title} a fost arhivat. Proiectul {title} nu va mai primi actualizări, cu excepția cazului în care autorul decide să-l dezarhiveze."
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "Versiuni"
|
||||
},
|
||||
|
||||
@@ -3953,9 +3953,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Загрузите версию перед тем, как выбрать теги!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} помещён в архив. {title} больше не будет получать обновления, если только автор не решит разархивировать проект."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Копировать ID"
|
||||
},
|
||||
|
||||
@@ -3227,9 +3227,6 @@
|
||||
"project.settings.tags.resolutions-description": {
|
||||
"message": "Välj upplösning(en/arna) på dina texturer i ditt {type}."
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} har arkiverats. {title} kommer inte få några ytterligare uppdateringar såvida inte författaren bestämmer sig för att avarkivera projektet."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Kopiera ID"
|
||||
},
|
||||
|
||||
@@ -3539,9 +3539,6 @@
|
||||
"project.settings.tags.title": {
|
||||
"message": "Etiketler"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} arşivlenmiş. {title}, yapımcı fikrini değiştirmediği sürece daha fazla güncelleme almayacak."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "ID'yi Kopyala"
|
||||
},
|
||||
|
||||
@@ -3971,9 +3971,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "Спершу завантажте версію, щоб обирати теґи!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "«{title}» було архівовано. «{title}» не отримуватиме подальших оновлень допоки автор не вирішить розархівувати проєкт."
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "Копіювати ID"
|
||||
},
|
||||
|
||||
@@ -3179,9 +3179,6 @@
|
||||
"project.settings.permissions.learn-more": {
|
||||
"message": "Tìm hiểu thêm"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} đã được lưu trữ. {title} sẽ không nhận được bất kỳ bản cập nhật nào trong tương lai trừ khi tác giả quyết định hủy lưu trữ dự án."
|
||||
},
|
||||
"project.versions.title": {
|
||||
"message": "Phiên bản"
|
||||
},
|
||||
|
||||
@@ -3974,9 +3974,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "请先上传一个版本再选择标签!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} 已归档。除非作者决定取消归档,否则 {title} 将不再更新。"
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "复制 ID"
|
||||
},
|
||||
|
||||
@@ -3974,9 +3974,6 @@
|
||||
"project.settings.tags.upload-version-first": {
|
||||
"message": "請先上傳一個版本再選擇標籤!"
|
||||
},
|
||||
"project.status.archived.message": {
|
||||
"message": "{title} 已封存。{title} 將不會再收到任何後續更新,除非作者決定解除封存該專案。"
|
||||
},
|
||||
"project.versions.copy-id-option": {
|
||||
"message": "複製 ID"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { projectQueryOptions } from '~/composables/queries/project'
|
||||
import { projectQueryOptions, warmProjectCheckCaches } from '~/composables/queries/project'
|
||||
import { useAppQueryClient } from '~/composables/query-client'
|
||||
import { createModrinthClient } from '~/helpers/api.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
@@ -20,11 +20,11 @@ const PROJECT_TYPES = [
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const routeProjectParam = to.params.project
|
||||
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
|
||||
const routeParam = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
|
||||
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
|
||||
|
||||
// Only handle project routes
|
||||
if (!projectId || !routeType || !PROJECT_TYPES.includes(routeType)) {
|
||||
if (!routeParam || !routeType || !PROJECT_TYPES.includes(routeType)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,7 +35,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (import.meta.client) startLoading()
|
||||
|
||||
try {
|
||||
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
|
||||
// Resolve slug/ID to the canonical project ID, then fetch by ID only
|
||||
const { id: projectId } = await queryClient.fetchQuery(
|
||||
projectQueryOptions.check(routeParam, client),
|
||||
)
|
||||
|
||||
warmProjectCheckCaches(queryClient, { id: projectId })
|
||||
|
||||
const [project, projectV3] = await Promise.all([
|
||||
queryClient.fetchQuery(projectQueryOptions.v2(projectId, client)),
|
||||
queryClient.fetchQuery(projectQueryOptions.v3(projectId, client)),
|
||||
@@ -44,15 +50,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Let page handle 404
|
||||
if (!project) return
|
||||
|
||||
// Cache by slug if we looked up by ID (or vice versa)
|
||||
if (projectId !== project.slug) {
|
||||
queryClient.setQueryData(['project', 'v2', project.slug], project)
|
||||
queryClient.setQueryData(['project', 'v3', project.slug], projectV3)
|
||||
}
|
||||
if (projectId !== project.id) {
|
||||
queryClient.setQueryData(['project', 'v2', project.id], project)
|
||||
queryClient.setQueryData(['project', 'v3', project.id], projectV3)
|
||||
}
|
||||
warmProjectCheckCaches(queryClient, project)
|
||||
|
||||
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
|
||||
// Determine the correct URL type
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
<ProjectDownloadModal
|
||||
ref="downloadModal"
|
||||
:project-id="routeProjectId"
|
||||
:project-id="projectId"
|
||||
:download-reason="downloadReason"
|
||||
@download="triggerDownloadAnimation"
|
||||
/>
|
||||
@@ -136,7 +136,7 @@
|
||||
v-if="
|
||||
projectV3 &&
|
||||
currentMember &&
|
||||
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
|
||||
(projectV3.status === 'draft' || tags.rejectedStatuses.includes(projectV3.status))
|
||||
"
|
||||
:project="project"
|
||||
:project-v3="projectV3"
|
||||
@@ -153,7 +153,7 @@
|
||||
v-if="projectV3Loaded"
|
||||
:project="project"
|
||||
:project-v3="projectV3"
|
||||
:show-status-badge="!!currentMember || project.status !== 'approved'"
|
||||
:show-status-badge="!!currentMember || projectV3.status !== 'approved'"
|
||||
@category="(category) => router.push(`${projectSearchUrl}?f=categories:${category}`)"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -443,9 +443,12 @@
|
||||
<SettingsIcon /> {{ formatMessage(messages.reviewEnvironmentSettings) }}
|
||||
</Button>
|
||||
</Admonition>
|
||||
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="my-4">
|
||||
{{ formatMessage(messages.archivedMessage, { title: project.title }) }}
|
||||
</MessageBanner>
|
||||
<ArchivedProjectBanner
|
||||
v-if="isArchived"
|
||||
:title="project.title"
|
||||
:reason="archivedDisclosure?.note"
|
||||
class="mt-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__sidebar">
|
||||
@@ -469,7 +472,7 @@
|
||||
:project-v3="projectV3"
|
||||
class="card flex-card"
|
||||
/>
|
||||
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
|
||||
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(projectV3.status)" />
|
||||
<ProjectSidebarLinks
|
||||
:project="project"
|
||||
:project-v3="projectV3"
|
||||
@@ -535,6 +538,7 @@ import {
|
||||
import { moderationSettings } from '@modrinth/moderation'
|
||||
import {
|
||||
Admonition,
|
||||
ArchivedProjectBanner,
|
||||
Avatar,
|
||||
BrowseInstallHeader,
|
||||
Button,
|
||||
@@ -579,7 +583,6 @@ import { onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } fro
|
||||
import { navigateTo } from '#app'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
||||
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
||||
import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue'
|
||||
@@ -588,7 +591,7 @@ import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue
|
||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
import { STALE_TIME, STALE_TIME_LONG, warmProjectCheckCaches } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
import { useServerInstallContent } from '~/composables/use-server-install-content'
|
||||
import { userCollectProject, userFollowProject } from '~/composables/user.js'
|
||||
@@ -615,8 +618,11 @@ const { addNotification } = notifications
|
||||
const auth = await useAuth()
|
||||
const user = await useUser()
|
||||
|
||||
// Route param for initial lookup (middleware caches by both slug and ID)
|
||||
const routeProjectId = ref(useRouteId('project'))
|
||||
// Route slug or ID — resolve to canonical ID before fetching project data
|
||||
const routeParam = computed(() => {
|
||||
const param = route.params.project
|
||||
return Array.isArray(param) ? param[0] : param
|
||||
})
|
||||
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
|
||||
@@ -678,11 +684,6 @@ function handlePlayServerProject() {
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
archivedMessage: {
|
||||
id: 'project.status.archived.message',
|
||||
defaultMessage:
|
||||
'{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project.',
|
||||
},
|
||||
backToAllProjects: {
|
||||
id: 'project.settings.back-to-all-projects',
|
||||
defaultMessage: 'Back to all projects',
|
||||
@@ -838,7 +839,7 @@ const collections = computed(() =>
|
||||
)
|
||||
|
||||
if (
|
||||
!routeProjectId.value ||
|
||||
!routeParam.value ||
|
||||
!(
|
||||
tags.value.projectTypes.find((x) => x.id === route.params.type) ||
|
||||
route.params.type === 'project'
|
||||
@@ -855,11 +856,42 @@ if (
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// V2 Project - hits middleware cache (uses route param for lookup)
|
||||
const { data: projectRaw, error: projectV2Error } = useQuery({
|
||||
queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value),
|
||||
// Resolve route slug/ID to the canonical project ID (middleware warms this cache)
|
||||
const { data: projectCheck, error: projectCheckError } = useQuery({
|
||||
queryKey: computed(() => ['project', 'check', routeParam.value]),
|
||||
queryFn: () => client.labrinth.projects_v2.check(routeParam.value),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: computed(() => !!routeParam.value),
|
||||
})
|
||||
|
||||
const projectId = computed(() => projectCheck.value?.id)
|
||||
|
||||
watch(
|
||||
projectCheckError,
|
||||
(error) => {
|
||||
if (error) {
|
||||
const status = error.statusCode ?? error.status ?? 500
|
||||
showError({
|
||||
fatal: true,
|
||||
statusCode: status,
|
||||
message:
|
||||
status === 404
|
||||
? formatMessage(messages.projectNotFound)
|
||||
: formatMessage(messages.errorLoadingProject, {
|
||||
message: error.message ? `: ${error.message}` : '',
|
||||
}),
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// V2 Project — keyed by canonical ID
|
||||
const { data: projectRaw, error: projectV2Error } = useQuery({
|
||||
queryKey: computed(() => ['project', 'v2', projectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v2.get(projectId.value),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: computed(() => !!projectId.value),
|
||||
})
|
||||
|
||||
// Handle project not found - use showError since watch runs outside Nuxt context
|
||||
@@ -943,9 +975,6 @@ const projectHeaderInstallContext = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Use actual project ID for dependent queries (ensures cache consistency)
|
||||
const projectId = computed(() => projectRaw.value?.id)
|
||||
|
||||
const sharedProjectId = injectCurrentProjectId(null)
|
||||
if (sharedProjectId) {
|
||||
watchEffect(() => {
|
||||
@@ -962,9 +991,10 @@ const {
|
||||
error: _projectV3Error,
|
||||
isPending: projectV3Pending,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['project', 'v3', routeProjectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v3.get(routeProjectId.value),
|
||||
queryKey: computed(() => ['project', 'v3', projectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v3.get(projectId.value),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: computed(() => !!projectId.value),
|
||||
})
|
||||
|
||||
// Server sidebar: modpack version + project for required content
|
||||
@@ -1123,6 +1153,19 @@ const { data: organizationRaw } = useQuery({
|
||||
// Return null when the project no longer belongs to an organization.
|
||||
const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null))
|
||||
|
||||
const DISCLOSURE_STALE_TIME = 1000 * 60 * 5
|
||||
const { data: disclosuresResponse } = useQuery({
|
||||
queryKey: computed(() => ['project', 'disclosures', 'v3', projectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v3.getDisclosures(projectId.value),
|
||||
staleTime: DISCLOSURE_STALE_TIME,
|
||||
enabled: computed(() => !!projectId.value),
|
||||
})
|
||||
|
||||
const archivedDisclosure = computed(() =>
|
||||
disclosuresResponse.value?.disclosures?.find((disclosure) => disclosure.type === 'archived'),
|
||||
)
|
||||
const isArchived = computed(() => !!archivedDisclosure.value)
|
||||
|
||||
const { data: thread } = useQuery({
|
||||
queryKey: computed(() => ['thread', projectRaw.value?.thread_id]),
|
||||
queryFn: () => client.labrinth.threads_v3.getThread(projectRaw.value.thread_id),
|
||||
@@ -1178,62 +1221,93 @@ function loadDependencies() {
|
||||
const hasVersions = computed(() => (project.value?.versions?.length ?? 0) > 0)
|
||||
|
||||
async function invalidateProject() {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId.value] })
|
||||
if (routeProjectId.value !== projectId.value) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId.value] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId.value] })
|
||||
const id = projectId.value
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', id] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', id] })
|
||||
// Prefix match — invalidates members, versions, dependencies, organization
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', id] })
|
||||
}
|
||||
|
||||
async function redirectIfNewSlug(newSlug, id) {
|
||||
if (newSlug === undefined || newSlug === route.params.project) {
|
||||
return
|
||||
}
|
||||
|
||||
warmProjectCheckCaches(queryClient, { id, slug: newSlug })
|
||||
|
||||
await navigateTo(
|
||||
{
|
||||
name: route.name,
|
||||
params: {
|
||||
type: route.params.type,
|
||||
project: newSlug,
|
||||
},
|
||||
query: route.query,
|
||||
hash: route.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
function mergeV3ProjectPatch(old, data) {
|
||||
if (!old) {
|
||||
return old
|
||||
}
|
||||
const merged = { ...old }
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
merged[key] &&
|
||||
typeof merged[key] === 'object' &&
|
||||
!Array.isArray(merged[key])
|
||||
) {
|
||||
merged[key] = { ...merged[key], ...value }
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Mutation for patching project data
|
||||
const patchProjectMutation = useMutation({
|
||||
mutationFn: async ({ projectId, data }) => {
|
||||
await client.labrinth.projects_v2.edit(projectId, data)
|
||||
if (data.slug !== undefined && data.slug !== route.params.project) {
|
||||
routeProjectId.value = data.slug
|
||||
await navigateTo(
|
||||
{
|
||||
name: route.name,
|
||||
params: {
|
||||
type: route.params.type,
|
||||
project: data.slug,
|
||||
},
|
||||
query: route.query,
|
||||
hash: route.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
await redirectIfNewSlug(data.slug, projectId)
|
||||
return data
|
||||
},
|
||||
|
||||
onMutate: async ({ projectId, data }) => {
|
||||
// Cancel outgoing refetches for both slug-based and ID-based cache keys
|
||||
// The query may be keyed by slug (routeProjectId.value) but we also have the actual UUID (projectId)
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
|
||||
if (routeProjectId.value !== projectId) {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
|
||||
}
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
|
||||
|
||||
// Snapshot previous value from the active query (uses route param as key)
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
|
||||
const previousV2 = queryClient.getQueryData(['project', 'v2', projectId])
|
||||
const previousV3 = queryClient.getQueryData(['project', 'v3', projectId])
|
||||
|
||||
// Optimistic update on the active query key
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
|
||||
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
|
||||
if (!old) return old
|
||||
return { ...old, ...data }
|
||||
})
|
||||
if (data.slug !== undefined) {
|
||||
queryClient.setQueryData(['project', 'v3', projectId], (old) =>
|
||||
old ? { ...old, slug: data.slug } : old,
|
||||
)
|
||||
}
|
||||
|
||||
return { previousProject }
|
||||
return { previousV2, previousV3, projectId }
|
||||
},
|
||||
|
||||
onError: (err, _variables, context) => {
|
||||
// Rollback on error using the active query key
|
||||
if (context?.previousProject) {
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
|
||||
if (context?.previousV2) {
|
||||
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousV2)
|
||||
}
|
||||
if (context?.previousV3) {
|
||||
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousV3)
|
||||
}
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
@@ -1254,28 +1328,21 @@ const patchStatusMutation = useMutation({
|
||||
},
|
||||
|
||||
onMutate: async ({ projectId, status }) => {
|
||||
// Cancel outgoing refetches for both slug-based and ID-based cache keys
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
|
||||
if (routeProjectId.value !== projectId) {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
|
||||
}
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
|
||||
|
||||
// Snapshot previous value from the active query (uses route param as key)
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
|
||||
|
||||
// Optimistic update on the active query key
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
|
||||
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
|
||||
if (!old) return old
|
||||
return { ...old, status }
|
||||
})
|
||||
|
||||
return { previousProject }
|
||||
return { previousProject, projectId }
|
||||
},
|
||||
|
||||
onError: (err, _variables, context) => {
|
||||
// Rollback on error using the active query key
|
||||
if (context?.previousProject) {
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
|
||||
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
|
||||
}
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
@@ -1293,40 +1360,33 @@ const patchStatusMutation = useMutation({
|
||||
const patchProjectV3Mutation = useMutation({
|
||||
mutationFn: async ({ projectId, data }) => {
|
||||
await client.labrinth.projects_v3.edit(projectId, data)
|
||||
await redirectIfNewSlug(data.slug, projectId)
|
||||
return data
|
||||
},
|
||||
|
||||
onMutate: async ({ projectId, data }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
|
||||
|
||||
const previousProject = queryClient.getQueryData(['project', 'v3', projectId])
|
||||
const previousV3 = queryClient.getQueryData(['project', 'v3', projectId])
|
||||
const previousV2 = queryClient.getQueryData(['project', 'v2', projectId])
|
||||
|
||||
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
|
||||
if (!old) return old
|
||||
const merged = { ...old }
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
merged[key] &&
|
||||
typeof merged[key] === 'object' &&
|
||||
!Array.isArray(merged[key])
|
||||
) {
|
||||
merged[key] = { ...merged[key], ...value }
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
queryClient.setQueryData(['project', 'v3', projectId], (old) => mergeV3ProjectPatch(old, data))
|
||||
if (data.slug !== undefined) {
|
||||
queryClient.setQueryData(['project', 'v2', projectId], (old) =>
|
||||
old ? { ...old, slug: data.slug } : old,
|
||||
)
|
||||
}
|
||||
|
||||
return { previousProject, projectId }
|
||||
return { previousV3, previousV2, projectId }
|
||||
},
|
||||
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProject) {
|
||||
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousProject)
|
||||
if (context?.previousV3) {
|
||||
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousV3)
|
||||
}
|
||||
if (context?.previousV2) {
|
||||
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousV2)
|
||||
}
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
@@ -1380,12 +1440,12 @@ const createGalleryItemMutation = useMutation({
|
||||
})
|
||||
},
|
||||
|
||||
onMutate: async ({ title, description, featured, ordering }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
|
||||
onMutate: async ({ projectId, title, description, featured, ordering }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
|
||||
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
|
||||
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
|
||||
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
|
||||
if (!old) return old
|
||||
const newItem = {
|
||||
url: '',
|
||||
@@ -1402,12 +1462,12 @@ const createGalleryItemMutation = useMutation({
|
||||
}
|
||||
})
|
||||
|
||||
return { previousProject }
|
||||
return { previousProject, projectId }
|
||||
},
|
||||
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProject) {
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
|
||||
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
|
||||
}
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
@@ -1431,12 +1491,12 @@ const editGalleryItemMutation = useMutation({
|
||||
})
|
||||
},
|
||||
|
||||
onMutate: async ({ imageUrl, title, description, featured, ordering }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
|
||||
onMutate: async ({ projectId, imageUrl, title, description, featured, ordering }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
|
||||
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
|
||||
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
|
||||
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
@@ -1455,12 +1515,12 @@ const editGalleryItemMutation = useMutation({
|
||||
}
|
||||
})
|
||||
|
||||
return { previousProject }
|
||||
return { previousProject, projectId }
|
||||
},
|
||||
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProject) {
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
|
||||
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
|
||||
}
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
@@ -1479,12 +1539,12 @@ const deleteGalleryItemMutation = useMutation({
|
||||
await client.labrinth.projects_v2.deleteGalleryImage(projectId, imageUrl)
|
||||
},
|
||||
|
||||
onMutate: async ({ imageUrl }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
|
||||
onMutate: async ({ projectId, imageUrl }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
|
||||
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
|
||||
const previousProject = queryClient.getQueryData(['project', 'v2', projectId])
|
||||
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
|
||||
queryClient.setQueryData(['project', 'v2', projectId], (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
@@ -1492,12 +1552,12 @@ const deleteGalleryItemMutation = useMutation({
|
||||
}
|
||||
})
|
||||
|
||||
return { previousProject }
|
||||
return { previousProject, projectId }
|
||||
},
|
||||
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProject) {
|
||||
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
|
||||
queryClient.setQueryData(['project', 'v2', context.projectId], context.previousProject)
|
||||
}
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
@@ -1750,10 +1810,7 @@ if (!route.name.startsWith('type-project-settings')) {
|
||||
ogDescription: () => project.value?.description ?? '',
|
||||
ogImage: () => project.value?.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
ogUrl: createCanonicalUrl,
|
||||
robots: () =>
|
||||
project.value?.status === 'approved' || project.value?.status === 'archived'
|
||||
? 'all'
|
||||
: 'noindex',
|
||||
robots: () => (project.value?.status === 'approved' ? 'all' : 'noindex'),
|
||||
})
|
||||
} else {
|
||||
useSeoMeta({
|
||||
|
||||
@@ -19,6 +19,7 @@ import { computed, watch } from 'vue'
|
||||
import {
|
||||
AdvertisingDisclosureCard,
|
||||
AiDisclosureCard,
|
||||
ArchivedDisclosureCard,
|
||||
DerivativeDisclosureCard,
|
||||
type DisclosureFormIssue,
|
||||
disclosuresToForm,
|
||||
@@ -169,6 +170,11 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
v-model="current.systemInteractions"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<ArchivedDisclosureCard
|
||||
v-model="current.archived"
|
||||
:disabled="!hasPermission"
|
||||
:project-title="project.title"
|
||||
/>
|
||||
</div>
|
||||
<UnsavedChangesPopup
|
||||
:original="saved"
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div>
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete this project?"
|
||||
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
|
||||
:title="formatMessage(messages.deleteConfirmationTitle)"
|
||||
:description="formatMessage(messages.deleteConfirmationDescription)"
|
||||
:has-to-type="true"
|
||||
:confirmation-text="project.title"
|
||||
proceed-label="Delete"
|
||||
:confirmation-text="project.name"
|
||||
:proceed-label="formatMessage(messages.deleteConfirmationProceedText)"
|
||||
@proceed="deleteProject"
|
||||
/>
|
||||
<section class="universal-card">
|
||||
@@ -35,9 +35,7 @@
|
||||
</label>
|
||||
<div class="text-input-wrapper !w-full">
|
||||
<div class="text-input-wrapper__before">
|
||||
<span class="hidden sm:inline">https://modrinth.com</span>/{{
|
||||
$getProjectTypeForUrl(project.project_type, project.loaders)
|
||||
}}/
|
||||
<span class="hidden sm:inline">https://modrinth.com</span>/{{ projectTypeForUrl }}/
|
||||
</div>
|
||||
<StyledInput
|
||||
id="project-slug"
|
||||
@@ -77,7 +75,7 @@
|
||||
<div class="input-group">
|
||||
<Avatar
|
||||
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
|
||||
:alt="project.title"
|
||||
:alt="project.name"
|
||||
size="md"
|
||||
class="project__icon"
|
||||
/>
|
||||
@@ -182,52 +180,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
!isServerProject &&
|
||||
!flags.newProjectEnvironmentSettings &&
|
||||
project.versions?.length !== 0 &&
|
||||
project.project_type !== 'resourcepack' &&
|
||||
project.project_type !== 'plugin' &&
|
||||
project.project_type !== 'shader' &&
|
||||
project.project_type !== 'datapack'
|
||||
"
|
||||
>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-client">
|
||||
<span class="label__title">Client-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
client side. Just because a mod works in Singleplayer doesn't mean it has actual
|
||||
client-side functionality.
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="clientSide"
|
||||
:options="sideTypeOptions"
|
||||
placeholder="Select one"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-server">
|
||||
<span class="label__title">Server-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
|
||||
server.
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="serverSide"
|
||||
:options="sideTypeOptions"
|
||||
placeholder="Select one"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div id="visibility">
|
||||
<label>
|
||||
<span class="label__title">Visibility</span>
|
||||
@@ -243,40 +195,49 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="!isServerProject" class="mt-4 flex flex-col gap-2">
|
||||
<div class="grid grid-cols-[1fr_auto] items-center gap-6">
|
||||
<label for="project-monetization-toggle">
|
||||
<span class="mb-1 block text-lg font-semibold text-contrast">Monetization</span>
|
||||
<span class="block">
|
||||
When enabled, this project can earn revenue through Modrinth's
|
||||
<nuxt-link to="/legal/cmp-info" target="_blank" class="text-link"
|
||||
>Rewards Program</nuxt-link
|
||||
>. If you don't want to (or can't for legal reasons) earn revenue from this project,
|
||||
you can turn it off here.
|
||||
</span>
|
||||
</label>
|
||||
<Toggle
|
||||
id="project-monetization-toggle"
|
||||
v-model="monetizationEnabled"
|
||||
:disabled="monetizationToggleDisabled"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isForceDemonetized" class="mt-2 flex flex-wrap items-center gap-2 text-orange">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<div class="mt-6 flex flex-col gap-4">
|
||||
<h2 class="m-0 text-2xl font-semibold">
|
||||
{{ formatMessage(messages.dangerZone) }}
|
||||
</h2>
|
||||
<SettingsToggleCard
|
||||
v-if="!isServerProject"
|
||||
v-model="monetizationEnabled"
|
||||
:disabled="monetizationToggleDisabled"
|
||||
:title="formatMessage(messages.monetizationTitle)"
|
||||
>
|
||||
<p>
|
||||
<IntlFormatted :message-id="messages.monetizationDescription">
|
||||
<template #rewards-program-link="{ children }">
|
||||
<nuxt-link
|
||||
to="/legal/cmp-info"
|
||||
target="_blank"
|
||||
class="smart-clickable:allow-pointer-events text-link"
|
||||
>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<div v-if="isForceDemonetized" class="mt-2 flex flex-wrap items-center gap-1.5 text-orange">
|
||||
<TriangleAlertIcon aria-hidden="true" class="size-4" />
|
||||
<span>
|
||||
Your project is not eligible for monetization. If you think this is a mistake, please
|
||||
<a
|
||||
class="text-orange underline hover:brightness-110"
|
||||
href="https://support.modrinth.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
contact support</a
|
||||
>.
|
||||
<IntlFormatted :message-id="messages.monetizationDisabledDescription">
|
||||
<template #contact-support-link="{ children }">
|
||||
<a
|
||||
class="smart-clickable:allow-pointer-events text-orange underline hover:brightness-110"
|
||||
href="https://support.modrinth.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isStaff" class="mt-2">
|
||||
<div v-if="isStaff" class="smart-clickable:allow-pointer-events mt-2">
|
||||
<Button
|
||||
v-if="!isForceDemonetized"
|
||||
type="colored"
|
||||
@@ -296,29 +257,33 @@
|
||||
Allow monetization
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="universal-card">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Delete project</span>
|
||||
</h3>
|
||||
</div>
|
||||
<p>
|
||||
Removes your project from Modrinth's servers and search. Clicking on this will delete your
|
||||
project, so be extra careful!
|
||||
</p>
|
||||
<Button
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="!hasDeletePermission"
|
||||
@click="$refs.modal_confirm.show()"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Delete project
|
||||
</Button>
|
||||
</section>
|
||||
</SettingsToggleCard>
|
||||
<SettingsOptionCard :title="formatMessage(messages.deleteProjectTitle)">
|
||||
<p>
|
||||
<IntlFormatted :message-id="messages.deleteProjectDescription1">
|
||||
<template #emphasis="{ children }">
|
||||
<span class="font-medium text-red"
|
||||
><component :is="() => normalizeChildren(children)"
|
||||
/></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<p>
|
||||
{{ formatMessage(messages.deleteProjectDescription2) }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<Button
|
||||
type="colored"
|
||||
color="red"
|
||||
:disabled="!hasDeletePermission"
|
||||
@click="$refs.modal_confirm.show()"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.deleteProjectButton) }}
|
||||
</Button>
|
||||
</template>
|
||||
</SettingsOptionCard>
|
||||
</div>
|
||||
<UnsavedChangesPopup
|
||||
:original="original"
|
||||
:modified="modified"
|
||||
@@ -340,29 +305,34 @@ import {
|
||||
commonProjectSettingsMessages,
|
||||
ConfirmLeaveModal,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
FileButton,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
SettingsOptionCard,
|
||||
SettingsToggleCard,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
UnsavedChangesPopup,
|
||||
useFormatBytes,
|
||||
usePageLeaveSafety,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
|
||||
import { fileIsValid, formatProjectStatus } from '@modrinth/utils'
|
||||
|
||||
import { useAuth } from '~/composables/auth.js'
|
||||
import { useFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { getProjectTypeForUrl } from '~/helpers/projects.js'
|
||||
|
||||
const auth = await useAuth()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
projectV2: project,
|
||||
projectV3,
|
||||
projectV3: project,
|
||||
currentMember,
|
||||
patchProject,
|
||||
patchProjectV3,
|
||||
patchIcon,
|
||||
invalidate,
|
||||
} = injectProjectPageContext()
|
||||
@@ -370,20 +340,16 @@ const { labrinth } = injectModrinthClient()
|
||||
|
||||
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const tags = useGeneratedState()
|
||||
const router = useNativeRouter()
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const name = ref(project.value.title)
|
||||
const slug = ref(project.value.slug)
|
||||
const summary = ref(project.value.description)
|
||||
const name = ref(project.value.name)
|
||||
const slug = ref(project.value.slug ?? '')
|
||||
const summary = ref(project.value.summary)
|
||||
const icon = ref(null)
|
||||
const previewImage = ref(null)
|
||||
const clientSide = ref(project.value.client_side)
|
||||
const serverSide = ref(project.value.server_side)
|
||||
const deletedIcon = ref(false)
|
||||
const visibility = ref(
|
||||
tags.value.approvedStatuses.includes(project.value.status)
|
||||
@@ -409,12 +375,17 @@ const isForceDemonetized = computed(() => project.value.monetization_status ===
|
||||
|
||||
// Server project specific refs
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
const isServerProject = computed(() => project.value?.minecraft_server != null)
|
||||
const projectTypeForUrl = computed(() => {
|
||||
if (isServerProject.value) return 'server'
|
||||
const type = project.value.project_types?.[0] ?? 'mod'
|
||||
return getProjectTypeForUrl(type, project.value.loaders)
|
||||
})
|
||||
const bannerPreview = ref(null)
|
||||
const deletedBanner = ref(false)
|
||||
const bannerFile = ref(null)
|
||||
const bannerGalleryImage = computed(() =>
|
||||
project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME),
|
||||
project.value.gallery?.find((img) => img.name === MC_SERVER_BANNER_NAME),
|
||||
)
|
||||
const hasPermission = computed(() => {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
@@ -439,53 +410,41 @@ const summaryWarning = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const sideTypeOptions = [
|
||||
{ value: 'required', label: 'Required' },
|
||||
{ value: 'optional', label: 'Optional' },
|
||||
{ value: 'unsupported', label: 'Unsupported' },
|
||||
]
|
||||
|
||||
const visibilityOptions = computed(() =>
|
||||
tags.value.approvedStatuses.map((status) => {
|
||||
const subLabel = () => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'Visible via URL, on your profile, and in search.'
|
||||
case 'archived':
|
||||
return 'Visible via URL, on your profile, and in search, but marked as archived.'
|
||||
case 'unlisted':
|
||||
return 'Visible via URL only. Not shown on your profile or in search.'
|
||||
case 'private':
|
||||
return 'Not publicly visible. Only accessible to project members.'
|
||||
default:
|
||||
return ''
|
||||
tags.value.approvedStatuses
|
||||
.filter((status) => status !== 'archived')
|
||||
.map((status) => {
|
||||
const subLabel = () => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'Visible via URL, on your profile, and in search.'
|
||||
case 'unlisted':
|
||||
return 'Visible via URL only. Not shown on your profile or in search.'
|
||||
case 'private':
|
||||
return 'Not publicly visible. Only accessible to project members.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: status,
|
||||
label: formatProjectStatus(status),
|
||||
subLabel: subLabel(),
|
||||
}
|
||||
}),
|
||||
return {
|
||||
value: status,
|
||||
label: formatProjectStatus(status),
|
||||
subLabel: subLabel(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const basePatchData = computed(() => {
|
||||
const data = {}
|
||||
|
||||
if (name.value !== project.value.title) {
|
||||
data.title = name.value.trim()
|
||||
if (name.value !== project.value.name) {
|
||||
data.name = name.value.trim()
|
||||
}
|
||||
if (slug.value !== project.value.slug) {
|
||||
if (slug.value !== (project.value.slug ?? '')) {
|
||||
data.slug = slug.value.trim()
|
||||
}
|
||||
if (summary.value !== project.value.description) {
|
||||
data.description = summary.value.trim()
|
||||
}
|
||||
if (clientSide.value !== project.value.client_side) {
|
||||
data.client_side = clientSide.value
|
||||
}
|
||||
if (serverSide.value !== project.value.server_side) {
|
||||
data.server_side = serverSide.value
|
||||
if (summary.value !== project.value.summary) {
|
||||
data.summary = summary.value.trim()
|
||||
}
|
||||
if (tags.value.approvedStatuses.includes(project.value.status)) {
|
||||
if (visibility.value !== project.value.status) {
|
||||
@@ -508,11 +467,9 @@ const basePatchData = computed(() => {
|
||||
const saving = ref(false)
|
||||
|
||||
const original = computed(() => ({
|
||||
name: project.value.title,
|
||||
slug: project.value.slug,
|
||||
summary: project.value.description,
|
||||
clientSide: project.value.client_side,
|
||||
serverSide: project.value.server_side,
|
||||
name: project.value.name,
|
||||
slug: project.value.slug ?? '',
|
||||
summary: project.value.summary,
|
||||
visibility: tags.value.approvedStatuses.includes(project.value.status)
|
||||
? project.value.status
|
||||
: project.value.requested_status,
|
||||
@@ -527,8 +484,6 @@ const modified = computed(() => ({
|
||||
name: name.value,
|
||||
slug: slug.value,
|
||||
summary: summary.value,
|
||||
clientSide: clientSide.value,
|
||||
serverSide: serverSide.value,
|
||||
visibility: visibility.value,
|
||||
icon: icon.value,
|
||||
deletedIcon: deletedIcon.value,
|
||||
@@ -544,11 +499,9 @@ const hasChanges = computed(() =>
|
||||
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
|
||||
|
||||
function resetChanges() {
|
||||
name.value = project.value.title
|
||||
slug.value = project.value.slug
|
||||
summary.value = project.value.description
|
||||
clientSide.value = project.value.client_side
|
||||
serverSide.value = project.value.server_side
|
||||
name.value = project.value.name
|
||||
slug.value = project.value.slug ?? ''
|
||||
summary.value = project.value.summary
|
||||
visibility.value = tags.value.approvedStatuses.includes(project.value.status)
|
||||
? project.value.status
|
||||
: project.value.requested_status
|
||||
@@ -564,7 +517,7 @@ function resetChanges() {
|
||||
async function updateMonetizationStatus(status) {
|
||||
loadingModeratorMonetization.value = true
|
||||
try {
|
||||
await patchProject({ monetization_status: status })
|
||||
await patchProjectV3({ monetization_status: status })
|
||||
} finally {
|
||||
loadingModeratorMonetization.value = false
|
||||
}
|
||||
@@ -573,10 +526,10 @@ async function updateMonetizationStatus(status) {
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const hasV2Changes = Object.keys(basePatchData.value).length > 0
|
||||
const hasPatchChanges = Object.keys(basePatchData.value).length > 0
|
||||
|
||||
if (hasV2Changes) {
|
||||
await patchProject(basePatchData.value)
|
||||
if (hasPatchChanges) {
|
||||
await patchProjectV3(basePatchData.value)
|
||||
}
|
||||
|
||||
if (deletedIcon.value) {
|
||||
@@ -633,18 +586,16 @@ const uploadBanner = async () => {
|
||||
if (!bannerFile.value) return
|
||||
|
||||
try {
|
||||
// First, delete existing banner image if there is one
|
||||
const existingBanner = project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME)
|
||||
const existingBanner = project.value.gallery?.find((img) => img.name === MC_SERVER_BANNER_NAME)
|
||||
if (existingBanner) {
|
||||
await labrinth.projects_v2.deleteGalleryImage(project.value.id, existingBanner.url)
|
||||
await labrinth.projects_v3.deleteGalleryImage(project.value.id, existingBanner.url)
|
||||
}
|
||||
|
||||
// Upload new banner as gallery image with special title
|
||||
const ext = bannerFile.value.type.split('/').pop() ?? 'png'
|
||||
await labrinth.projects_v2.createGalleryImage(project.value.id, bannerFile.value, {
|
||||
await labrinth.projects_v3.createGalleryImage(project.value.id, bannerFile.value, {
|
||||
ext,
|
||||
featured: false,
|
||||
title: MC_SERVER_BANNER_NAME,
|
||||
name: MC_SERVER_BANNER_NAME,
|
||||
})
|
||||
|
||||
await invalidate()
|
||||
@@ -664,9 +615,9 @@ const uploadBanner = async () => {
|
||||
|
||||
const deleteBanner = async () => {
|
||||
try {
|
||||
const bannerImage = project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME)
|
||||
const bannerImage = project.value.gallery?.find((img) => img.name === MC_SERVER_BANNER_NAME)
|
||||
if (bannerImage) {
|
||||
await labrinth.projects_v2.deleteGalleryImage(project.value.id, bannerImage.url)
|
||||
await labrinth.projects_v3.deleteGalleryImage(project.value.id, bannerImage.url)
|
||||
await invalidate()
|
||||
addNotification({
|
||||
title: 'Banner removed',
|
||||
@@ -684,9 +635,7 @@ const deleteBanner = async () => {
|
||||
}
|
||||
|
||||
const deleteProject = async () => {
|
||||
await useBaseFetch(`project/${project.value.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await labrinth.projects_v3.deleteProject(project.value.id)
|
||||
await initUserProjects()
|
||||
await router.push('/dashboard/projects')
|
||||
addNotification({
|
||||
@@ -703,9 +652,7 @@ const markIconForDeletion = () => {
|
||||
}
|
||||
|
||||
const deleteIcon = async () => {
|
||||
await useBaseFetch(`project/${project.value.id}/icon`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await labrinth.projects_v3.deleteIcon(project.value.id)
|
||||
await invalidate()
|
||||
addNotification({
|
||||
title: 'Project icon removed',
|
||||
@@ -713,6 +660,55 @@ const deleteIcon = async () => {
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
dangerZone: {
|
||||
id: 'project.settings.danger-zone',
|
||||
defaultMessage: 'Danger zone',
|
||||
},
|
||||
monetizationTitle: {
|
||||
id: 'project.settings.monetization.title',
|
||||
defaultMessage: 'Monetization',
|
||||
},
|
||||
monetizationDescription: {
|
||||
id: 'project.settings.monetization.description',
|
||||
defaultMessage: `Projects on Modrinth are automatically enrolled in the <rewards-program-link>Rewards Program</rewards-program-link>. If you don't want to (or can't for legal reasons) earn revenue from this project, you can turn it off here.`,
|
||||
},
|
||||
monetizationDisabledDescription: {
|
||||
id: 'project.settings.monetization.disabled-description',
|
||||
defaultMessage: `This project is not eligible for monetization. If you think this is a mistake, please <contact-support-link>contact support</contact-support-link>.`,
|
||||
},
|
||||
deleteProjectTitle: {
|
||||
id: 'project.settings.delete-project.title',
|
||||
defaultMessage: 'Delete project',
|
||||
},
|
||||
deleteProjectButton: {
|
||||
id: 'project.settings.delete-project.button',
|
||||
defaultMessage: 'Delete project',
|
||||
},
|
||||
deleteProjectDescription1: {
|
||||
id: 'project.settings.delete-project.description.1',
|
||||
defaultMessage:
|
||||
'Permanently deletes this project from Modrinth. Deleted projects <emphasis>cannot be recovered</emphasis> by Modrinth staff or support.',
|
||||
},
|
||||
deleteProjectDescription2: {
|
||||
id: 'project.settings.delete-project.description.2',
|
||||
defaultMessage:
|
||||
'Files uploaded to this project that are actively used in Modpacks hosted on Modrinth may continue to exist.',
|
||||
},
|
||||
deleteConfirmationTitle: {
|
||||
id: 'project.settings.delete-project.confirmation.title',
|
||||
defaultMessage: 'Are you sure you want to delete this project?',
|
||||
},
|
||||
deleteConfirmationDescription: {
|
||||
id: 'project.settings.delete-project.confirmation.description',
|
||||
defaultMessage: `If you proceed, all of this project's information and all of its versions will be immediately deleted from our database. None of it is recoverable later if you change your mind. Consider just setting your project to be Private for a less permanent option.`,
|
||||
},
|
||||
deleteConfirmationProceedText: {
|
||||
id: 'project.settings.delete-project.confirmation.proceed-text',
|
||||
defaultMessage: 'Permanently delete project',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { LocationQueryRaw } from 'vue-router'
|
||||
|
||||
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import { projectQueryOptions } from '~/composables/queries/project'
|
||||
import { projectQueryOptions, warmProjectCheckCaches } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
import type {
|
||||
ServerInstallModalHandle,
|
||||
@@ -67,14 +67,15 @@ let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
|
||||
const HOVER_DURATION_TO_PREFETCH_MS = 500
|
||||
|
||||
const handleProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
const slug = result.slug || result.project_id
|
||||
const projectId = result.project_id
|
||||
prefetchTimeout = useTimeoutFn(
|
||||
() => {
|
||||
queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, client))
|
||||
warmProjectCheckCaches(queryClient, result)
|
||||
queryClient.prefetchQuery(projectQueryOptions.v2(projectId, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.v3(projectId, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.members(projectId, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.dependencies(projectId, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.versionsV3(projectId, client))
|
||||
},
|
||||
HOVER_DURATION_TO_PREFETCH_MS,
|
||||
{ immediate: false },
|
||||
@@ -83,12 +84,13 @@ const handleProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject)
|
||||
}
|
||||
|
||||
const handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
const slug = result.slug || result.project_id
|
||||
const projectId = result.project_id
|
||||
|
||||
prefetchTimeout = useTimeoutFn(
|
||||
async () => {
|
||||
queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.v3(slug, client))
|
||||
warmProjectCheckCaches(queryClient, result)
|
||||
queryClient.prefetchQuery(projectQueryOptions.v2(projectId, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.v3(projectId, client))
|
||||
|
||||
const content = result.minecraft_java_server?.content
|
||||
if (content?.kind === 'modpack' && content.version_id) {
|
||||
@@ -445,6 +447,15 @@ const searchState = useBrowseSearch({
|
||||
})
|
||||
setBrowseSearchState(searchState)
|
||||
|
||||
// Warm check caches for every visible hit so clicking a result skips /project/{slug}/check
|
||||
watch(
|
||||
[() => searchState.projectHits.value, () => searchState.serverHits.value],
|
||||
([projectHits, serverHits]) => {
|
||||
warmProjectCheckCaches(queryClient, [...projectHits, ...serverHits])
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() =>
|
||||
searchState.isServerType.value
|
||||
|
||||
@@ -240,13 +240,14 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { Organization, ProjectStatus, ProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import OrganizationPageHeader from '~/components/ui/OrganizationPageHeader.vue'
|
||||
import { warmProjectCheckCaches } from '~/composables/queries/project'
|
||||
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
||||
import {
|
||||
OrganizationContext,
|
||||
@@ -295,6 +296,7 @@ if (route.path.includes('settings')) {
|
||||
const routeHasSettings = computed(() => route.path.includes('settings'))
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
data: organization,
|
||||
@@ -345,6 +347,14 @@ const {
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
watch(
|
||||
projects,
|
||||
(list) => {
|
||||
warmProjectCheckCaches(queryClient, list)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([refreshOrganization(), refreshProjects()])
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useQueryClient } from '@tanstack/vue-query'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import { warmProjectCheckCaches } from '~/composables/queries/project'
|
||||
|
||||
const route = useNativeRoute()
|
||||
const client = injectModrinthClient()
|
||||
@@ -59,7 +60,7 @@ try {
|
||||
// Let the mounted layout's useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
const [projectsResult] = await Promise.allSettled([
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['user', userId.value, 'projects'],
|
||||
queryFn: () => userProfile.getProjects(userId.value),
|
||||
@@ -77,6 +78,9 @@ await Promise.allSettled([
|
||||
}),
|
||||
])
|
||||
|
||||
if (projectsResult.status === 'fulfilled') {
|
||||
warmProjectCheckCaches(queryClient, projectsResult.value)
|
||||
}
|
||||
const title = computed(() =>
|
||||
prefetchedUser ? `${prefetchedUser.username} - Modrinth` : 'Modrinth',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user