Merge branch 'main' into truman/mc-account-modal

This commit is contained in:
Prospector
2026-07-21 12:00:32 -07:00
committed by GitHub
92 changed files with 3056 additions and 835 deletions
+4
View File
@@ -101,6 +101,10 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
+4
View File
@@ -109,6 +109,10 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
Generated
+70
View File
@@ -3856,6 +3856,12 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.31"
@@ -4167,6 +4173,29 @@ dependencies = [
"system-deps",
]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.3",
"hashbrown 0.16.0",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.2",
"smallvec",
"spinning_top",
"web-time",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -6300,6 +6329,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
@@ -7900,6 +7935,21 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi 0.11.1+wasi-snapshot-preview1",
"web-sys",
"winapi",
]
[[package]]
name = "quartz_nbt"
version = "0.2.9"
@@ -8227,6 +8277,15 @@ dependencies = [
"rgb",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.9.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -9838,6 +9897,15 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -10940,8 +11008,10 @@ dependencies = [
"fs4",
"futures",
"futures-lite 2.6.1",
"governor",
"heck 0.5.0",
"hickory-resolver 0.25.2",
"httpdate",
"indicatif",
"itertools 0.14.0",
"modrinth-content-management",
+2
View File
@@ -89,10 +89,12 @@ fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.1"
futures-util = "0.3.31"
governor = "0.10.4"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
httpdate = "1.0.3"
hyper = "1.7.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"aws-lc-rs",
@@ -455,9 +455,6 @@
"app.project.install-context.back-to-browse": {
"message": "العودة إلى الاستكشاف"
},
"app.project.install-context.install-content-to-instance": {
"message": "تثبيت المحتويات الى النموذج"
},
"app.project.version.all-versions": {
"message": "جميع الإصدارات"
},
@@ -392,9 +392,6 @@
"app.project.install-context.back-to-browse": {
"message": "Zpět k Objevování"
},
"app.project.install-context.install-content-to-instance": {
"message": "Nainstalovat obsah do instance"
},
"app.project.version.all-versions": {
"message": "Všechny verze"
},
@@ -296,9 +296,6 @@
"app.project.install-context.back-to-browse": {
"message": "Tilbage til udforsk"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installer indhold til instance"
},
"app.project.versions.already-installed": {
"message": "Allerede installeret"
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Zurück zu Entdecken"
},
"app.project.install-context.install-content-to-instance": {
"message": "Inhalt in Instanz installieren"
},
"app.project.version.all-versions": {
"message": "Alle Versionen"
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Zurück zum Entdecken"
},
"app.project.install-context.install-content-to-instance": {
"message": "Inhalt in Instanz installieren"
},
"app.project.version.all-versions": {
"message": "Alle Versionen"
},
@@ -494,9 +494,6 @@
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.install-content-to-instance": {
"message": "Install content to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Volver al explorador"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instalar contenido a la instancia"
},
"app.project.version.all-versions": {
"message": "Todas las versiones"
},
@@ -296,9 +296,6 @@
"app.project.install-context.back-to-browse": {
"message": "Volver a descubrir"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instalar contenido"
},
"app.settings.developer-mode-enabled": {
"message": "Modo desarrollador activado."
},
@@ -377,9 +377,6 @@
"app.project.install-context.back-to-browse": {
"message": "Takaisin sisällön löytöön"
},
"app.project.install-context.install-content-to-instance": {
"message": "Asenna sisältöä instanssiin"
},
"app.settings.developer-mode-enabled": {
"message": "Kehittäjätila käytössä."
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Retour vers la page découvrir"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installer du contenu à l'instance"
},
"app.project.version.all-versions": {
"message": "Toutes les versions"
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Vissza a böngészéshez"
},
"app.project.install-context.install-content-to-instance": {
"message": "Tartalom telepítése a profilba"
},
"app.project.version.all-versions": {
"message": "Összes verzió"
},
@@ -272,9 +272,6 @@
"app.project.install-context.back-to-browse": {
"message": "Kembali ke halaman pencarian"
},
"app.project.install-context.install-content-to-instance": {
"message": "Pasang konten ke instans"
},
"app.settings.developer-mode-enabled": {
"message": "Mode pengembang dihidupkan."
},
@@ -476,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Torna a Esplora"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installa il contenuto nell'istanza"
},
"app.project.version.all-versions": {
"message": "Tutte le versioni"
},
@@ -479,21 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "탐색으로 돌아가기"
},
"app.project.install-context.install-content-to-instance": {
"message": "인스턴스에 콘텐츠 설치"
},
"app.project.version.all-versions": {
"message": "모든 버전"
},
"app.project.version.download-in-browser": {
"message": "브라우저에서 다운로드"
},
"app.project.version.installing": {
"message": "설치 중"
},
"app.project.versions.already-installed": {
"message": "이미 설치됨"
},
"app.settings.developer-mode-enabled": {
"message": "개발자 모드가 활성화되었습니다."
},
@@ -287,9 +287,6 @@
"app.project.install-context.back-to-browse": {
"message": "Kembali menemukan"
},
"app.project.install-context.install-content-to-instance": {
"message": "Pasang kandungan ke dalam pemasangan"
},
"app.settings.developer-mode-enabled": {
"message": "Mod pembangun didayakan."
},
@@ -479,21 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Terug naar Ontdekken"
},
"app.project.install-context.install-content-to-instance": {
"message": "Inhoud op instantie installeren"
},
"app.project.version.all-versions": {
"message": "Alle versies"
},
"app.project.version.download-in-browser": {
"message": "Downloaden in de browser"
},
"app.project.version.installing": {
"message": "Wordt geïnstalleerd"
},
"app.project.versions.already-installed": {
"message": "Al geïnstalleerd"
},
"app.settings.developer-mode-enabled": {
"message": "Ontwikkelaarsmodus ingeschakeld."
},
@@ -476,9 +476,6 @@
"app.project.install-context.back-to-browse": {
"message": "Powróć do odkrywania"
},
"app.project.install-context.install-content-to-instance": {
"message": "Zainstaluj zasoby do instancji"
},
"app.project.version.all-versions": {
"message": "Wszystkie wersje"
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Voltar a descobrir"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instalar conteúdo para a instância"
},
"app.project.version.all-versions": {
"message": "Todas as versões"
},
@@ -473,9 +473,6 @@
"app.project.install-context.back-to-browse": {
"message": "Вернуться к поиску"
},
"app.project.install-context.install-content-to-instance": {
"message": "Установка контента в сборку"
},
"app.project.version.all-versions": {
"message": "Все версии"
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Nazad da otkrivanje"
},
"app.project.install-context.install-content-to-instance": {
"message": "Instaliraj sadržaj na instancu"
},
"app.project.version.all-versions": {
"message": "Sve verzije"
},
@@ -446,9 +446,6 @@
"app.project.install-context.back-to-browse": {
"message": "Tillbaka till upptäck"
},
"app.project.install-context.install-content-to-instance": {
"message": "Installera innehåll till instans"
},
"app.project.version.all-versions": {
"message": "Alla versioner"
},
@@ -272,9 +272,6 @@
"app.project.install-context.back-to-browse": {
"message": "กลับไปที่หน้าค้นหา"
},
"app.project.install-context.install-content-to-instance": {
"message": "ติดตั้งเนื้อหาลงสู่อินสแตนซ์"
},
"app.settings.developer-mode-enabled": {
"message": "โหมดนักพัฒนาเปิดอยู่"
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Keşfete geri dön"
},
"app.project.install-context.install-content-to-instance": {
"message": "İçeriği kuruluma yükle"
},
"app.project.version.all-versions": {
"message": "Tüm sürümler"
},
@@ -479,21 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "Повернутися до дослідження"
},
"app.project.install-context.install-content-to-instance": {
"message": "Установити вміст до профілю"
},
"app.project.version.all-versions": {
"message": "Усі версії"
},
"app.project.version.download-in-browser": {
"message": "Завантажити в браузері"
},
"app.project.version.installing": {
"message": "Установлення"
},
"app.project.versions.already-installed": {
"message": "Уже встановлено"
},
"app.settings.developer-mode-enabled": {
"message": "Увімкнено режим розробника."
},
@@ -329,21 +329,6 @@
"app.project.install-context.back-to-browse": {
"message": "Quay lại trang Khám Phá"
},
"app.project.install-context.install-content-to-instance": {
"message": "Cài đặt nội dung vào phiên bản"
},
"app.project.version.all-versions": {
"message": "Tất cả phiên bản"
},
"app.project.version.download-in-browser": {
"message": "Tải trong trình duyệt"
},
"app.project.version.installing": {
"message": "Đang tải"
},
"app.project.versions.already-installed": {
"message": "Đã có sẵn"
},
"app.settings.developer-mode-enabled": {
"message": "Chế độ nhà phát triển đã được bật."
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "返回发现页"
},
"app.project.install-context.install-content-to-instance": {
"message": "将内容安装到实例中"
},
"app.project.version.all-versions": {
"message": "所有版本"
},
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": {
"message": "返回瀏覽"
},
"app.project.install-context.install-content-to-instance": {
"message": "將內容安裝至實例"
},
"app.project.version.all-versions": {
"message": "所有版本"
},
+9 -1
View File
@@ -72,6 +72,7 @@ const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
const themeStore = useTheming()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
provideServerInstallContent(serverInstallContent)
@@ -490,6 +491,9 @@ function resetInstanceContext() {
watch(
() => route.params.projectType as ProjectType,
async (newType) => {
if (!browseRouteActive.value) {
return
}
if (isSetupServerContext.value) {
enforceSetupModpackRoute(newType)
if (newType !== 'modpack') return
@@ -960,6 +964,7 @@ const lockedFilterMessages = computed(() => ({
const searchState = useBrowseSearch({
projectType,
tags,
active: browseRouteActive,
providedFilters: combinedProvidedFilters,
search,
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
@@ -1035,6 +1040,9 @@ onUnmounted(() => {
})
function getProjectBrowseQuery() {
if (!browseRouteActive.value) {
return undefined
}
if (!installContext.value) return undefined
return {
...route.query,
@@ -1139,7 +1147,7 @@ provideBrowseManager({
@browse-modpacks="() => {}"
@create="handleServerModpackFlowCreate"
/>
<Teleport to="#sidebar-teleport-target">
<Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
<BrowseSidebar />
</Teleport>
</div>
@@ -320,10 +320,6 @@ const messages = defineMessages({
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
},
installContentToInstance: {
id: 'app.project.install-context.install-content-to-instance',
defaultMessage: 'Install content to instance',
},
switchVersion: {
id: 'app.project.install-button.switch-version',
defaultMessage: 'Switch version',
@@ -450,7 +446,7 @@ const projectInstallContext = computed(() => {
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
heading: formatMessage(messages.installContentToInstance),
heading: formatMessage(commonMessages.installingContentLabel),
}
}
+591 -106
View File
@@ -1,19 +1,158 @@
const MODRINTH_ORIGIN = 'https://modrinth.com'
function installAdsConsentThemeStyle() {
if (document.getElementById('modrinth-ads-consent-theme-style')) return
const style = document.createElement('style')
style.id = 'modrinth-ads-consent-theme-style'
style.textContent = `
:root {
--modrinth-usp-bg: #27292e;
--modrinth-usp-surface: #34363c;
--modrinth-usp-divider: #34363c;
--modrinth-usp-text: #b0bac5;
--modrinth-usp-contrast: #ffffff;
--modrinth-usp-brand: #1bd96a;
--modrinth-usp-link: #4f9cff;
--modrinth-usp-accent-contrast: #000000;
--modrinth-usp-shadow: rgba(0, 0, 0, 0.1) 0 4px 6px -1px,
rgba(0, 0, 0, 0.06) 0 2px 4px -1px;
color-scheme: dark;
}
#qc-cmp2-usp {
background: var(--modrinth-usp-bg) !important;
border: 1px solid var(--modrinth-usp-divider) !important;
border-radius: 1rem !important;
box-shadow: var(--modrinth-usp-shadow) !important;
color: var(--modrinth-usp-text) !important;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Roboto,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif !important;
max-width: 660px;
}
#qc-cmp2-usp .qc-usp-ui-content,
#qc-cmp2-usp .qc-usp-ui-form-content,
#qc-cmp2-usp .qc-usp-container {
background: transparent !important;
}
#qc-cmp2-usp .qc-usp-container{
margin-bottom: 12px;
}
#qc-cmp2-usp p,
#qc-cmp2-usp label,
#qc-cmp2-usp .qc-usp-action-description {
color: var(--modrinth-usp-text) !important;
font-family: inherit !important;
}
#qc-cmp2-usp .qc-usp-title,
#qc-cmp2-usp .qc-cmp2-list-item-title {
color: var(--modrinth-usp-contrast) !important;
font-family: inherit !important;
font-weight: 700 !important;
}
#qc-cmp2-usp .qc-usp-title {
font-size: 1.25rem !important;
}
#qc-cmp2-usp a,
#qc-cmp2-usp .qc-usp-alt-action {
color: var(--modrinth-usp-link) !important;
}
#qc-cmp2-usp .qc-cmp2-list-item {
background: var(--modrinth-usp-surface) !important;
border: 1px solid var(--modrinth-usp-divider) !important;
border-radius: 0.75rem !important;
}
#qc-cmp2-usp .qc-cmp2-list-item-header {
background: transparent !important;
border: 0 !important;
color: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-list-item-header svg {
color: var(--modrinth-usp-text) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle {
background: var(--modrinth-usp-bg) !important;
border-color: var(--modrinth-usp-divider) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle .toggle {
background: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle .text {
color: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] {
background: var(--modrinth-usp-brand) !important;
border-color: var(--modrinth-usp-brand) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] .text {
color: var(--modrinth-usp-accent-contrast) !important;
}
#qc-cmp2-usp button[mode='primary'] {
background: var(--modrinth-usp-brand) !important;
border: 0 !important;
border-radius: 0.75rem !important;
color: var(--modrinth-usp-accent-contrast) !important;
font-family: inherit !important;
font-weight: 700 !important;
}
#qc-cmp2-usp .qc-usp-close-icon {
border: 0 !important;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
center / 1.5rem 1.5rem no-repeat;
}
#qc-cmp2-usp a:focus-visible,
#qc-cmp2-usp button:focus-visible {
outline: 2px solid var(--modrinth-usp-brand) !important;
outline-offset: 2px !important;
}
#qc-cmp2-usp .qc-usp-ui-content {
max-width: 100% !important;
}
#qc-cmp2-usp .qc-usp-ui-content .qc-usp-ui-form-content {
border: 1px solid transparent !important;
padding: 0 !important;
}
`
document.documentElement.appendChild(style)
}
document.addEventListener(
'click',
function (e) {
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
let target = e.target
if (target?.closest?.('.qc-cmp2-close-icon')) {
if (modrinthAdsConsentReprompt) {
const uspCloseIcon = target?.closest?.('.qc-usp-close-icon')
if (target?.closest?.('.qc-cmp2-close-icon') || uspCloseIcon) {
if (isAdsConsentReprompt()) {
setTimeout(finishAdsConsentReprompt)
} else if (document.documentElement.classList.contains('modrinth-ads-consent-preferences')) {
setTimeout(() => void restoreAdsConsentNotification())
}
}
if (target?.closest?.('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) {
beginUspConsentCommit()
}
while (target != null) {
if (target.matches('a')) {
e.preventDefault()
@@ -32,14 +171,54 @@ window.open = (url, target, features) => {
window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN)
}
let modrinthAdsConsentOverlayShown = false
const modrinthAdsConsentState = {
phase: 'idle',
variant: null,
commitTimeout: null,
}
let modrinthTcfListenerInstalled = false
let modrinthTcfListenerAttempts = 0
let modrinthAdsConsentReprompt = false
let modrinthAdsConsentRepromptManaging = false
let modrinthGppListenerInstalled = false
let modrinthGppListenerAttempts = 0
let modrinthAdsConsentActionRequestId = 0
const modrinthAdsConsentActionResolvers = new Map()
function transitionAdsConsent(event) {
const phase = modrinthAdsConsentState.phase
if (event === 'prompt-detected') {
if (phase === 'idle') modrinthAdsConsentState.phase = 'initial'
} else if (event === 'reprompt-started') {
modrinthAdsConsentState.phase = 'reprompt'
} else if (event === 'commit-started') {
modrinthAdsConsentState.phase = isAdsConsentReprompt()
? 'reprompt-committing'
: 'initial-committing'
} else if (event === 'commit-timed-out') {
if (phase === 'reprompt-committing') {
modrinthAdsConsentState.phase = 'reprompt'
} else if (phase === 'initial-committing') {
modrinthAdsConsentState.phase = 'initial'
}
} else if (event === 'completed') {
modrinthAdsConsentState.phase = 'complete'
}
}
function isAdsConsentReprompt() {
return (
modrinthAdsConsentState.phase === 'reprompt' ||
modrinthAdsConsentState.phase === 'reprompt-committing'
)
}
function isUspConsentCommitPending() {
return (
modrinthAdsConsentState.phase === 'initial-committing' ||
modrinthAdsConsentState.phase === 'reprompt-committing'
)
}
function installAdsRailStyle() {
if (document.getElementById('modrinth-ads-rail-style')) {
return
@@ -48,8 +227,7 @@ function installAdsRailStyle() {
style.id = 'modrinth-ads-rail-style'
style.textContent = `
html.modrinth-ads-consent-preferences #modrinth-rail-1 {
visibility: hidden !important;
pointer-events: none !important;
display: none !important;
}
`
document.documentElement.appendChild(style)
@@ -62,18 +240,13 @@ function installAdsConsentOverlayStyle() {
const style = document.createElement('style')
style.id = 'modrinth-ads-consent-overlay-style'
style.textContent = `
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #modrinth-rail-1 {
visibility: hidden !important;
}
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-container,
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-main {
visibility: hidden !important;
pointer-events: none !important;
html.modrinth-ads-consent-preferences:not(.modrinth-ads-consent-preferences-visible) #qc-cmp2-container {
display: none !important;
}
.qc-cmp2-close-icon {
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath d='M.5.5l23 23m0-23l-23 23' fill='none' stroke='%23b0bac5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E") 0% 0% / 66% auto no-repeat !important;
#qc-cmp2-usp .qc-usp-close-icon {
display: none !important;
}
`
document.documentElement.appendChild(style)
@@ -96,13 +269,20 @@ function invokeAdsConsentOverlayCommand(shown) {
invoke(`plugin:ads|${command}`, args).catch(() => {})
}
function revealAdsConsentPreferences() {
function prepareAdsConsentPreferences() {
installAdsRailStyle()
installAdsConsentOverlayStyle()
document.documentElement.classList.add('modrinth-ads-consent-preferences')
document.getElementById('modrinth-ads-consent-overlay-style')?.remove()
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
}
function revealAdsConsentPreferences() {
document.documentElement.classList.add('modrinth-ads-consent-preferences-visible')
}
function concealAdsConsentPreferences() {
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
installAdsConsentOverlayStyle()
}
@@ -127,60 +307,181 @@ function isDirectChildFrame(source) {
)
}
function findAdsConsentButton(action) {
const container = document.querySelector('#qc-cmp2-container, #qc-cmp2-main')
if (!container) return null
const summaryButtons = Array.from(container.querySelectorAll('.qc-cmp2-summary-buttons button'))
function displayUspConsentUi() {
if (typeof window.__uspapi !== 'function') return false
if (action === 'accept') {
const explicitAcceptButton = container.querySelector('#accept-btn')
if (explicitAcceptButton) return explicitAcceptButton
if (summaryButtons.length >= 3) return summaryButtons[2]
return container.querySelector('.qc-cmp2-summary-buttons button[mode="primary"]')
try {
window.__uspapi('displayUspUi', 1, () => {})
return true
} catch {
return false
}
if (action === 'reject') {
const explicitRejectButton = container.querySelector('#disagree-btn')
if (explicitRejectButton) return explicitRejectButton
if (summaryButtons.length >= 3) return summaryButtons[1]
const secondaryButtons = container.querySelectorAll(
'.qc-cmp2-summary-buttons button[mode="secondary"]',
)
if (secondaryButtons.length > 1) return secondaryButtons[1]
return summaryButtons.find(
(button) => button.textContent?.trim().toLowerCase() === 'reject all',
)
}
return (
container.querySelector(
'#more-options-btn, .qc-cmp2-summary-buttons > button[mode="secondary"]:first-of-type',
) ?? summaryButtons[0]
)
}
function clickAdsConsentButtonWhenReady(action, timeoutMs, onButtonFound) {
const deadline = Date.now() + timeoutMs
function detectAdsConsentVariant() {
let variant = null
if (document.getElementById('qc-cmp2-usp')) {
variant = 'usp'
} else if (document.getElementById('qc-cmp2-ui')) {
variant = 'tcf'
}
if (variant) {
modrinthAdsConsentState.variant = variant
}
return variant
}
function findTcfConsentButton(action) {
const dialog = document.getElementById('qc-cmp2-ui')
if (!dialog) return null
const buttonIds = {
accept: 'accept-btn',
reject: 'disagree-btn',
manage: 'more-options-btn',
}
const buttonId = buttonIds[action]
if (!buttonId) return null
const button = dialog.querySelector(`#${buttonId}`)
return button && !button.disabled ? button : null
}
function getUspConsentControls() {
const dialog = document.getElementById('qc-cmp2-usp')
if (!dialog) return null
const toggles = Array.from(
dialog.querySelectorAll('.qc-usp-container button.qc-cmp2-toggle[role="switch"]'),
)
const confirmButton = dialog.querySelector('.qc-usp-ui-form-content button[mode="primary"]')
if (
toggles.length === 0 ||
!confirmButton ||
confirmButton.disabled ||
toggles.some(
(toggle) =>
toggle.disabled || !['true', 'false'].includes(toggle.getAttribute('aria-checked')),
)
) {
return null
}
return { toggles, confirmButton }
}
function waitForUspToggleState(index, checked, expectedCount, deadline) {
return new Promise((resolve) => {
function tryClick() {
const button = findAdsConsentButton(action)
if (button) {
// CMP navigation can replace this document during the click, so acknowledge it first.
onButtonFound?.()
resolve(true)
button.click()
function checkState() {
const controls = getUspConsentControls()
if (
controls &&
controls.toggles.length === expectedCount &&
controls.toggles[index]?.getAttribute('aria-checked') === String(checked)
) {
resolve(controls)
} else if (Date.now() >= deadline) {
resolve(false)
resolve(null)
} else {
setTimeout(tryClick, 50)
setTimeout(checkState, 50)
}
}
tryClick()
checkState()
})
}
async function setUspToggleStates(checked, controls, timeoutMs) {
const expectedCount = controls.toggles.length
const deadline = Date.now() + timeoutMs
for (let index = 0; index < expectedCount; index += 1) {
controls = getUspConsentControls()
if (!controls || controls.toggles.length !== expectedCount) return null
const toggle = controls.toggles[index]
if (toggle.getAttribute('aria-checked') !== String(checked)) {
toggle.click()
controls = await waitForUspToggleState(index, checked, expectedCount, deadline)
if (!controls) return null
}
}
return controls
}
async function performAdsConsentActionInDocument(action, onHandled) {
const variant = detectAdsConsentVariant()
const unknownDialog = document.querySelector('#qc-cmp2-container [role="dialog"]')
if (action === 'show') {
if (variant || unknownDialog) {
onHandled?.()
return 'handled'
}
return 'not-ready'
}
if (variant === 'usp') {
if (action === 'manage') {
onHandled?.()
return 'handled'
}
if (!['accept', 'reject'].includes(action)) return 'failed'
const controls = getUspConsentControls()
if (!controls) return 'not-ready'
const shouldOptOut = action === 'reject'
const settledControls = await setUspToggleStates(shouldOptOut, controls, 2000)
if (!settledControls) return 'failed'
// CMP navigation can replace this document during the click, so acknowledge it first.
onHandled?.()
beginUspConsentCommit()
settledControls.confirmButton.click()
return 'handled'
}
if (variant === 'tcf') {
const button = findTcfConsentButton(action)
if (!button) return 'not-ready'
// CMP navigation can replace this document during the click, so acknowledge it first.
onHandled?.()
button.click()
return 'handled'
}
if (action === 'manage' && unknownDialog) {
onHandled?.()
return 'handled'
}
return 'not-ready'
}
function performAdsConsentActionWhenReady(action, timeoutMs, onHandled) {
const deadline = Date.now() + timeoutMs
return new Promise((resolve) => {
async function tryAction() {
const result = await performAdsConsentActionInDocument(action, onHandled)
if (result === 'handled') {
resolve(true)
} else if (result === 'failed' || Date.now() >= deadline) {
resolve(false)
} else {
setTimeout(tryAction, 50)
}
}
void tryAction()
})
}
@@ -200,9 +501,7 @@ function performAdsConsentActionAcrossFrames(action, timeoutMs) {
const timeout = setTimeout(() => settle(false), timeoutMs)
modrinthAdsConsentActionResolvers.set(requestId, () => settle(true))
sendAdsConsentCommandToChildFrames({ type: 'perform', action, requestId, timeoutMs })
clickAdsConsentButtonWhenReady(action, timeoutMs, () => settle(true)).then((clicked) => {
if (!clicked) settle(false)
})
void performAdsConsentActionWhenReady(action, timeoutMs, () => settle(true))
})
}
@@ -220,10 +519,30 @@ async function restoreAdsConsentNotification() {
}
}
async function showNativeAdsConsentUi() {
prepareAdsConsentPreferences()
await waitForAdsConsentLayout()
sendAdsConsentCommandToChildFrames({ type: 'prepare' })
await expandAdsConsentWebview()
await waitForAdsConsentLayout()
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
const shown = await performAdsConsentActionAcrossFrames('show', 2500)
if (!shown) {
await restoreAdsConsentNotification()
}
return shown
}
function finishAdsConsentReprompt() {
modrinthAdsConsentReprompt = false
modrinthAdsConsentRepromptManaging = false
modrinthAdsConsentOverlayShown = false
transitionAdsConsent('completed')
clearTimeout(modrinthAdsConsentState.commitTimeout)
modrinthAdsConsentState.commitTimeout = null
document.documentElement.classList.remove('modrinth-ads-consent-overlay')
concealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'conceal' })
@@ -231,18 +550,9 @@ function finishAdsConsentReprompt() {
}
async function openAdsConsentPreferences() {
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
await expandAdsConsentWebview()
await waitForAdsConsentLayout()
if (!(await showNativeAdsConsentUi())) return
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
const clicked = await performAdsConsentActionAcrossFrames('manage', 2500)
if (!clicked) {
await restoreAdsConsentNotification()
}
await performAdsConsentActionAcrossFrames('manage', 2500)
}
async function performAdsConsentAction(action) {
@@ -257,30 +567,119 @@ async function performAdsConsentAction(action) {
return
}
await performAdsConsentActionAcrossFrames(action, 1000)
const handled = await performAdsConsentActionAcrossFrames(action, 2500)
if (!handled) {
try {
await showNativeAdsConsentUi()
} catch {
await restoreAdsConsentNotification()
}
}
}
window.modrinthAdsConsentAction = (action) => {
void performAdsConsentAction(action)
}
function isUspConsentApplicable() {
if (detectAdsConsentVariant() === 'usp') return Promise.resolve(true)
if (typeof window.__uspapi !== 'function') return Promise.resolve(false)
return new Promise((resolve) => {
let settled = false
const settle = (applicable) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(applicable)
}
const timeout = setTimeout(() => settle(false), 500)
try {
window.__uspapi('uspPing', 1, (data, success) => {
if (!success || !data) {
settle(false)
return
}
const modes = Array.isArray(data.mode) ? data.mode : [data.mode]
const jurisdictions = Array.isArray(data.jurisdiction)
? data.jurisdiction
: [data.jurisdiction]
const location = String(data.location ?? '').toUpperCase()
const hasUspMode = modes.some((mode) =>
String(mode ?? '')
.toUpperCase()
.includes('USP'),
)
const locationApplies =
!location ||
jurisdictions.some((jurisdiction) =>
String(jurisdiction ?? '')
.toUpperCase()
.includes(location),
)
settle(hasUspMode && locationApplies)
})
} catch {
settle(false)
}
})
}
async function displayAdsConsentReprompt() {
if (
(modrinthAdsConsentState.variant === 'usp' || (await isUspConsentApplicable())) &&
typeof window.__uspapi === 'function'
) {
modrinthAdsConsentState.variant = 'usp'
return displayUspConsentUi()
}
if (typeof window.__tcfapi === 'function') {
modrinthAdsConsentState.variant = 'tcf'
window.__tcfapi('displayConsentUi', 2, () => {})
return true
}
return false
}
window.modrinthAdsReopenConsentPreferences = async () => {
modrinthAdsConsentReprompt = true
modrinthAdsConsentRepromptManaging = false
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) {
try {
await openAdsConsentPreferences()
} catch {
await restoreAdsConsentNotification()
}
return
}
transitionAdsConsent('reprompt-started')
prepareAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'prepare' })
try {
await waitForAdsConsentLayout()
await expandAdsConsentWebview()
await waitForAdsConsentLayout()
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
if (typeof window.__tcfapi === 'function') {
window.__tcfapi('displayConsentUi', 2, () => {})
} else {
if (!(await displayAdsConsentReprompt())) {
finishAdsConsentReprompt()
return
}
if (!(await performAdsConsentActionAcrossFrames('show', 2500))) {
finishAdsConsentReprompt()
return
}
await performAdsConsentActionAcrossFrames('manage', 2500)
} catch {
finishAdsConsentReprompt()
}
@@ -302,7 +701,10 @@ window.addEventListener('message', (event) => {
const command = event.data?.modrinthAdsConsentCommand
if (!command || typeof command !== 'object') return
if (command.type === 'reveal') {
if (command.type === 'prepare') {
prepareAdsConsentPreferences()
sendAdsConsentCommandToChildFrames(command)
} else if (command.type === 'reveal') {
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames(command)
} else if (command.type === 'conceal') {
@@ -318,20 +720,20 @@ window.addEventListener('message', (event) => {
typeof command.timeoutMs === 'number'
) {
sendAdsConsentCommandToChildFrames(command)
clickAdsConsentButtonWhenReady(command.action, command.timeoutMs, () => {
performAdsConsentActionWhenReady(command.action, command.timeoutMs, () => {
window.parent.postMessage({ modrinthAdsConsentResult: command.requestId }, '*')
})
}
})
function setAdsConsentOverlay(shown) {
if (modrinthAdsConsentOverlayShown === shown) return
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay') === shown) return
modrinthAdsConsentOverlayShown = shown
installAdsConsentOverlayStyle()
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
if (!shown) {
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
}
if (window.top === window) {
@@ -343,36 +745,111 @@ function setAdsConsentOverlay(shown) {
if (window.top === window) {
window.addEventListener('message', (event) => {
if (
event.origin === MODRINTH_ORIGIN &&
typeof event.data?.modrinthAdsConsentOverlay === 'boolean'
) {
if (event.origin !== MODRINTH_ORIGIN) return
if (typeof event.data?.modrinthAdsConsentOverlay === 'boolean') {
setAdsConsentOverlay(event.data.modrinthAdsConsentOverlay)
}
})
}
function finishUspConsentCommit() {
if (!isUspConsentCommitPending()) return
const wasReprompt = isAdsConsentReprompt()
transitionAdsConsent('completed')
clearTimeout(modrinthAdsConsentState.commitTimeout)
modrinthAdsConsentState.commitTimeout = null
if (wasReprompt) {
finishAdsConsentReprompt()
} else {
setAdsConsentOverlay(false)
}
}
function beginUspConsentCommit() {
if (!document.getElementById('qc-cmp2-usp')) return
modrinthAdsConsentState.variant = 'usp'
transitionAdsConsent('commit-started')
clearTimeout(modrinthAdsConsentState.commitTimeout)
const deadline = Date.now() + 2500
function checkForDialogClosure() {
if (!isUspConsentCommitPending()) return
if (!document.getElementById('qc-cmp2-usp')) {
finishUspConsentCommit()
} else if (Date.now() >= deadline) {
transitionAdsConsent('commit-timed-out')
modrinthAdsConsentState.commitTimeout = null
} else {
modrinthAdsConsentState.commitTimeout = setTimeout(checkForDialogClosure, 50)
}
}
modrinthAdsConsentState.commitTimeout = setTimeout(checkForDialogClosure, 50)
}
function syncAdsConsentUi() {
const variant = detectAdsConsentVariant()
if (variant && !isAdsConsentReprompt() && modrinthAdsConsentState.phase !== 'complete') {
transitionAdsConsent('prompt-detected')
setAdsConsentOverlay(true)
}
if (isUspConsentCommitPending() && !document.getElementById('qc-cmp2-usp')) {
finishUspConsentCommit()
}
}
function handleGppConsentEvent(gppData, success) {
if (
success &&
gppData?.eventName === 'sectionChange' &&
modrinthAdsConsentState.variant === 'usp' &&
isUspConsentCommitPending()
) {
finishUspConsentCommit()
}
}
function installGppConsentListener() {
if (modrinthGppListenerInstalled) return
if (typeof window.__gpp === 'function') {
modrinthGppListenerInstalled = true
window.__gpp('addEventListener', handleGppConsentEvent)
return
}
if (modrinthGppListenerAttempts < 60) {
modrinthGppListenerAttempts += 1
setTimeout(installGppConsentListener, 500)
}
}
function handleTcfConsentEvent(tcData, success) {
if (!success || !tcData) return
detectAdsConsentVariant()
if (tcData.eventStatus === 'cmpuishown') {
if (modrinthAdsConsentReprompt) {
if (!modrinthAdsConsentRepromptManaging) {
modrinthAdsConsentRepromptManaging = true
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
void performAdsConsentActionAcrossFrames('manage', 2500)
}
return
}
if (isAdsConsentReprompt()) return
transitionAdsConsent('prompt-detected')
setAdsConsentOverlay(true)
} else if (tcData.eventStatus === 'useractioncomplete') {
if (modrinthAdsConsentReprompt) {
} else if (
tcData.eventStatus === 'useractioncomplete' &&
modrinthAdsConsentState.variant === 'tcf'
) {
if (isAdsConsentReprompt()) {
finishAdsConsentReprompt()
return
}
transitionAdsConsent('completed')
setAdsConsentOverlay(false)
}
}
@@ -469,14 +946,22 @@ function muteVideos() {
}
document.addEventListener('DOMContentLoaded', () => {
installAdsConsentThemeStyle()
installAdsRailStyle()
installAdsConsentOverlayStyle()
muteVideos()
muteAudioContext()
syncAdsConsentUi()
installTcfConsentListener()
installGppConsentListener()
const observer = new MutationObserver(muteVideos)
const observer = new MutationObserver(() => {
muteVideos()
syncAdsConsentUi()
})
observer.observe(document.body, { childList: true, subtree: true })
})
syncAdsConsentUi()
installTcfConsentListener()
installGppConsentListener()
+2
View File
@@ -582,6 +582,8 @@ pub async fn init_ads_window<R: Runtime>(
}
})?;
webview.open_devtools();
Some(webview)
} else {
None
+106
View File
@@ -487,3 +487,109 @@ input {
background-color: transparent;
color: hsl(145, 78%, 28%);
}
#qc-cmp2-usp {
background: var(--color-raised-bg) !important;
border: 1px solid var(--color-divider) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-floating) !important;
color: var(--color-base) !important;
font-family: var(--font-standard) !important;
max-width: 660px;
.qc-usp-ui-content,
.qc-usp-ui-form-content,
.qc-usp-container {
background: transparent !important;
}
p,
label,
.qc-usp-action-description {
color: var(--color-base) !important;
font-family: var(--font-standard) !important;
}
.qc-usp-title,
.qc-cmp2-list-item-title {
color: var(--color-contrast) !important;
font-family: var(--font-standard) !important;
font-weight: 700 !important;
}
.qc-usp-title {
font-size: 1.25rem !important;
}
a,
.qc-usp-alt-action {
color: var(--color-link) !important;
}
.qc-cmp2-list-item {
background: var(--color-button-bg) !important;
border: 1px solid var(--color-divider) !important;
border-radius: var(--radius-md) !important;
}
.qc-cmp2-list-item-header {
background: transparent !important;
border: 0 !important;
color: var(--color-contrast) !important;
}
.qc-cmp2-list-item-header svg {
color: var(--color-base) !important;
}
.qc-cmp2-toggle {
background: var(--surface-3) !important;
border-color: var(--color-divider) !important;
}
.qc-cmp2-toggle .toggle {
background: var(--color-contrast) !important;
}
.qc-cmp2-toggle .text {
color: var(--color-contrast) !important;
}
.qc-cmp2-toggle[aria-checked='true'] {
background: var(--color-brand) !important;
border-color: var(--color-brand) !important;
}
.qc-cmp2-toggle[aria-checked='true'] .text {
color: var(--color-accent-contrast) !important;
}
button[mode='primary'] {
background: var(--color-brand) !important;
border: 0 !important;
border-radius: var(--radius-md) !important;
color: var(--color-accent-contrast) !important;
font-family: var(--font-standard) !important;
font-weight: 700 !important;
}
.qc-usp-close-icon {
border: 0 !important;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
center / 1.5rem 1.5rem no-repeat;
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--color-brand) !important;
outline-offset: 2px !important;
}
.qc-usp-ui-content {
max-width: 100% !important;
}
.qc-usp-ui-content .qc-usp-ui-form-content {
border: 1px solid transparent !important;
padding: 0 !important;
}
}
@@ -775,19 +775,22 @@ function suppressUpcomingChartClick() {
}, 350)
}
function clearUnpinnedTouchInteraction(ignoreClick: boolean) {
function clearUnpinnedTouchInteraction(ignoreClick: boolean, pinHover = false) {
if (props.pinnedSliceIndex !== null) return
if (ignoreClick) {
suppressUpcomingChartClick()
}
if (pinHover) {
emit('touch-drag')
}
clearChartActiveState()
updateChartWithoutGeometry()
if (ignoreClick) {
suppressUpcomingChartClick()
emit('touch-drag')
return
if (!pinHover) {
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
}
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
}
function handleCanvasClickCapture(event: MouseEvent) {
@@ -918,7 +921,7 @@ function handleRangePointerEnd(event: PointerEvent) {
if (isRangeSelecting && rangeSelectPointerType === 'touch') {
event.preventDefault()
clearUnpinnedTouchInteraction(true)
clearUnpinnedTouchInteraction(true, true)
} else if (isRangeSelecting && startSliceIndex !== null && endSliceIndex !== null) {
event.preventDefault()
emit('range-select', { startSliceIndex, endSliceIndex })
@@ -110,6 +110,8 @@ export function useAnalyticsChartInteractions({
function onTouchDragEnd() {
ignoreUpcomingChartClick()
if (!hoverState.visible || hoverState.sliceIndex === null) return
isHoverPinned.value = true
}
function onChartGeometry(payload: AnalyticsChartGeometryPayload) {
@@ -213,6 +213,10 @@ export const analyticsMessages = defineMessages({
id: 'analytics.filter.game-version-type.all',
defaultMessage: 'All',
},
dependentProjectSearchFailedTitle: {
id: 'analytics.filter.dependent-project-search-failed-title',
defaultMessage: 'Dependent projects failed to load',
},
})
export const analyticsStatMessages = defineMessages({
@@ -492,7 +492,7 @@ watch(dependentProjectSearchError, (error) => {
if (!error) return
addNotification({
title: 'Dependent projects failed to load',
title: formatMessage(analyticsMessages.dependentProjectSearchFailedTitle),
text: getDependentProjectSearchErrorMessage(error),
type: 'error',
})
@@ -8,6 +8,7 @@ import {
import { onBeforeUnmount, onMounted } from 'vue'
type ConsentAction = 'accept' | 'reject' | 'manage'
type ConsentVariant = 'tcf' | 'usp'
interface TcfData {
eventStatus?: string
@@ -17,6 +18,19 @@ interface TcfData {
type TcfCallback = (data: TcfData, success: boolean) => void
type TcfApi = (command: string, version: number, callback: TcfCallback, parameter?: unknown) => void
interface UspControls {
toggles: HTMLButtonElement[]
confirmButton: HTMLButtonElement
}
interface GppData {
eventName?: string
listenerId?: number
}
type GppCallback = (data: GppData, success: boolean) => void
type GppApi = (command: string, callback: GppCallback, parameter?: unknown) => void
const CMP_HIDDEN_CLASS = 'modrinth-cmp-summary-hidden'
const notificationManager = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -47,8 +61,17 @@ const messages = defineMessages({
let notificationId: WebNotification['id'] | null = null
let tcfListenerId: number | undefined
let gppListenerId: number | undefined
let listenerInstalled = false
let gppListenerInstalled = false
let gppListenerAttempts = 0
let gppInstallTimeout: ReturnType<typeof setTimeout> | undefined
let managingPreferences = false
let consentComplete = false
let consentVariant: ConsentVariant | null = null
let uspConsentCommitPending = false
let uspSuccessModalDismissed = false
let uspCommitTimeout: ReturnType<typeof setTimeout> | undefined
let consentContainerObserver: MutationObserver | undefined
const consentContainerContains = new Map<HTMLElement, HTMLElement['contains']>()
@@ -56,31 +79,132 @@ function getTcfApi(): TcfApi | undefined {
return (window as typeof window & { __tcfapi?: TcfApi }).__tcfapi
}
function getGppApi(): GppApi | undefined {
return (window as typeof window & { __gpp?: GppApi }).__gpp
}
function setConsentUiHidden(hidden: boolean) {
document.documentElement.classList.toggle(CMP_HIDDEN_CLASS, hidden)
patchConsentFocusTrapContainer()
}
function patchConsentFocusTrapContainer() {
const container = document.querySelector<HTMLElement>('#qc-cmp2-ui')
if (!container || consentContainerContains.has(container)) return
const containers = document.querySelectorAll<HTMLElement>('#qc-cmp2-ui, #qc-cmp2-usp')
const originalContains = container.contains
// InMobi's focus trap otherwise cancels clicks outside its hidden container.
container.contains = (node: Node | null) =>
document.documentElement.classList.contains(CMP_HIDDEN_CLASS) ||
originalContains.call(container, node)
consentContainerContains.set(container, originalContains)
for (const container of containers) {
if (consentContainerContains.has(container)) continue
const originalContains = container.contains
// InMobi's focus trap otherwise cancels clicks outside its hidden container.
container.contains = (node: Node | null) =>
document.documentElement.classList.contains(CMP_HIDDEN_CLASS) ||
originalContains.call(container, node)
consentContainerContains.set(container, originalContains)
}
}
function detectConsentVariant(): ConsentVariant | null {
let variant: ConsentVariant | null = null
if (document.getElementById('qc-cmp2-usp')) {
variant = 'usp'
} else if (document.getElementById('qc-cmp2-ui')) {
variant = 'tcf'
}
if (variant) consentVariant = variant
return variant
}
function getConsentContainers(): ParentNode[] {
const containers = Array.from(
document.querySelectorAll<HTMLElement>('#qc-cmp2-container, #qc-cmp2-main, #qc-cmp2-ui'),
document.querySelectorAll<HTMLElement>(
'#qc-cmp2-container, #qc-cmp2-main, #qc-cmp2-ui, #qc-cmp2-usp',
),
)
return containers.length > 0 ? containers : [document]
}
function getUspConsentControls(): UspControls | null {
const dialog = document.getElementById('qc-cmp2-usp')
if (!dialog) return null
const toggles = Array.from(
dialog.querySelectorAll<HTMLButtonElement>(
'.qc-usp-container button.qc-cmp2-toggle[role="switch"]',
),
)
const confirmButton = dialog.querySelector<HTMLButtonElement>(
'.qc-usp-ui-form-content button[mode="primary"]',
)
if (
toggles.length === 0 ||
!confirmButton ||
confirmButton.disabled ||
toggles.some(
(toggle) =>
toggle.disabled || !['true', 'false'].includes(toggle.getAttribute('aria-checked') ?? ''),
)
) {
return null
}
return { toggles, confirmButton }
}
function waitForUspToggleState(
index: number,
checked: boolean,
expectedCount: number,
deadline: number,
): Promise<UspControls | null> {
return new Promise((resolve) => {
function checkState() {
const controls = getUspConsentControls()
if (
controls &&
controls.toggles.length === expectedCount &&
controls.toggles[index]?.getAttribute('aria-checked') === String(checked)
) {
resolve(controls)
} else if (Date.now() >= deadline) {
resolve(null)
} else {
setTimeout(checkState, 50)
}
}
checkState()
})
}
async function setUspToggleStates(
checked: boolean,
controls: UspControls,
timeoutMs: number,
): Promise<UspControls | null> {
const expectedCount = controls.toggles.length
const deadline = Date.now() + timeoutMs
for (let index = 0; index < expectedCount; index += 1) {
const currentControls = getUspConsentControls()
if (!currentControls || currentControls.toggles.length !== expectedCount) return null
controls = currentControls
const toggle = controls.toggles[index]
if (toggle.getAttribute('aria-checked') !== String(checked)) {
toggle.click()
const settledControls = await waitForUspToggleState(index, checked, expectedCount, deadline)
if (!settledControls) return null
controls = settledControls
}
}
return controls
}
function matchesButtonText(button: HTMLButtonElement, terms: string[]): boolean {
const text = [button.textContent, button.getAttribute('aria-label')]
.filter(Boolean)
@@ -173,9 +297,29 @@ function clickConsentButtonWhenReady(action: ConsentAction, timeoutMs: number):
}
async function performConsentAction(action: ConsentAction) {
const variant = detectConsentVariant()
if (action === 'manage') {
managingPreferences = true
setConsentUiHidden(false)
if (variant === 'usp') return
}
if (variant === 'usp' && action !== 'manage') {
const controls = getUspConsentControls()
const settledControls = controls
? await setUspToggleStates(action === 'reject', controls, 2000)
: null
if (settledControls) {
beginUspConsentCommit()
settledControls.confirmButton.click()
return
}
managingPreferences = false
setConsentUiHidden(false)
return
}
const clicked = await clickConsentButtonWhenReady(action, action === 'manage' ? 2500 : 1000)
@@ -194,6 +338,8 @@ async function performConsentAction(action: ConsentAction) {
}
function showConsentNotification() {
if (consentComplete) return
setConsentUiHidden(true)
if (
@@ -239,6 +385,10 @@ function showConsentNotification() {
function finishConsent() {
managingPreferences = false
consentComplete = true
uspConsentCommitPending = false
clearTimeout(uspCommitTimeout)
uspCommitTimeout = undefined
setConsentUiHidden(false)
if (notificationId !== null) {
@@ -247,8 +397,67 @@ function finishConsent() {
}
}
function beginUspConsentCommit() {
if (!document.getElementById('qc-cmp2-usp')) return
consentVariant = 'usp'
uspConsentCommitPending = true
uspSuccessModalDismissed = false
clearTimeout(uspCommitTimeout)
const deadline = Date.now() + 2500
function checkForDialogClosure() {
if (!uspConsentCommitPending) return
if (!document.getElementById('qc-cmp2-usp')) {
finishConsent()
} else if (Date.now() >= deadline) {
uspConsentCommitPending = false
uspCommitTimeout = undefined
} else {
uspCommitTimeout = setTimeout(checkForDialogClosure, 50)
}
}
uspCommitTimeout = setTimeout(checkForDialogClosure, 50)
}
function dismissUspSuccessModal() {
if ((!uspConsentCommitPending && !consentComplete) || uspSuccessModalDismissed) return
const closeButton = document.querySelector<HTMLElement>(
'#qc-cmp2-usp [aria-label="Close success modal"]',
)
if (!closeButton || (closeButton instanceof HTMLButtonElement && closeButton.disabled)) return
uspSuccessModalDismissed = true
closeButton.click()
}
function syncConsentUi() {
patchConsentFocusTrapContainer()
installGppConsentListener()
const variant = detectConsentVariant()
dismissUspSuccessModal()
if (
variant === 'usp' &&
!consentComplete &&
!uspConsentCommitPending &&
!managingPreferences &&
getUspConsentControls()
) {
showConsentNotification()
}
if (uspConsentCommitPending && !document.getElementById('qc-cmp2-usp')) {
finishConsent()
}
}
function handleTcfConsentEvent(data: TcfData, success: boolean) {
if (!success) return
detectConsentVariant()
if (data.listenerId !== undefined) {
tcfListenerId = data.listenerId
@@ -258,11 +467,47 @@ function handleTcfConsentEvent(data: TcfData, success: boolean) {
if (!managingPreferences) {
showConsentNotification()
}
} else if (data.eventStatus === 'useractioncomplete') {
} else if (data.eventStatus === 'useractioncomplete' && consentVariant === 'tcf') {
finishConsent()
}
}
function handleGppConsentEvent(data: GppData, success: boolean) {
if (data.listenerId !== undefined) {
gppListenerId = data.listenerId
}
if (
success &&
data.eventName === 'sectionChange' &&
consentVariant === 'usp' &&
uspConsentCommitPending
) {
finishConsent()
}
}
function installGppConsentListener() {
if (gppListenerInstalled) return
const gppApi = getGppApi()
if (!gppApi) {
if (gppListenerAttempts < 60 && gppInstallTimeout === undefined) {
gppListenerAttempts += 1
gppInstallTimeout = setTimeout(() => {
gppInstallTimeout = undefined
installGppConsentListener()
}, 500)
}
return
}
gppListenerInstalled = true
clearTimeout(gppInstallTimeout)
gppInstallTimeout = undefined
gppApi('addEventListener', handleGppConsentEvent)
}
function installTcfConsentListener() {
if (listenerInstalled) return
@@ -273,31 +518,56 @@ function installTcfConsentListener() {
tcfApi('addEventListener', 2, handleTcfConsentEvent)
}
function handleDocumentClick(event: MouseEvent) {
if (!managingPreferences || !(event.target instanceof Element)) return
if (!event.target.closest('.qc-cmp2-close-icon')) return
function restoreConsentNotification() {
setTimeout(() => {
if (notificationId === null) return
managingPreferences = false
setConsentUiHidden(true)
showConsentNotification()
})
}
function handleDocumentClick(event: MouseEvent) {
if (!(event.target instanceof Element)) return
if (event.target.closest('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) {
beginUspConsentCommit()
return
}
if (!managingPreferences) return
if (!event.target.closest('.qc-cmp2-close-icon, .qc-usp-close-icon')) return
restoreConsentNotification()
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && managingPreferences) {
restoreConsentNotification()
}
}
onMounted(() => {
consentContainerObserver = new MutationObserver(patchConsentFocusTrapContainer)
consentContainerObserver.observe(document.body, { childList: true, subtree: true })
patchConsentFocusTrapContainer()
consentContainerObserver = new MutationObserver(syncConsentUi)
consentContainerObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['aria-label', 'disabled'],
})
syncConsentUi()
installTcfConsentListener()
installGppConsentListener()
window.addEventListener('modrinth-cmp-ready', installTcfConsentListener)
document.addEventListener('click', handleDocumentClick, true)
document.addEventListener('keydown', handleDocumentKeydown, true)
})
onBeforeUnmount(() => {
consentContainerObserver?.disconnect()
clearTimeout(uspCommitTimeout)
clearTimeout(gppInstallTimeout)
window.removeEventListener('modrinth-cmp-ready', installTcfConsentListener)
document.removeEventListener('click', handleDocumentClick, true)
document.removeEventListener('keydown', handleDocumentKeydown, true)
setConsentUiHidden(false)
for (const [container, originalContains] of consentContainerContains) {
container.contains = originalContains
@@ -309,6 +579,11 @@ onBeforeUnmount(() => {
tcfApi('removeEventListener', 2, () => {}, tcfListenerId)
}
const gppApi = getGppApi()
if (gppApi && gppListenerId !== undefined) {
gppApi('removeEventListener', () => {}, gppListenerId)
}
if (notificationId !== null) {
notificationManager.removeNotification(notificationId)
}
@@ -319,7 +594,8 @@ onBeforeUnmount(() => {
html.modrinth-cmp-summary-hidden .qc-cmp2-container,
html.modrinth-cmp-summary-hidden #qc-cmp2-container,
html.modrinth-cmp-summary-hidden #qc-cmp2-main,
html.modrinth-cmp-summary-hidden #qc-cmp2-ui {
html.modrinth-cmp-summary-hidden #qc-cmp2-ui,
html.modrinth-cmp-summary-hidden #qc-cmp2-usp {
display: none !important;
z-index: -1 !important;
pointer-events: none !important;
@@ -40,6 +40,30 @@ const messages = defineMessages({
defaultMessage:
'NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.',
},
basedOnLabel: {
id: 'layout.footer.based-on-label',
defaultMessage: 'Based on',
},
unknownCommit: {
id: 'layout.footer.unknown-commit',
defaultMessage: 'unknown',
},
developerModeActivatedTitle: {
id: 'layout.footer.developer-mode-activated.title',
defaultMessage: 'Developer mode activated',
},
developerModeActivatedText: {
id: 'layout.footer.developer-mode-activated.text',
defaultMessage: 'Developer mode has been enabled',
},
developerModeDeactivatedTitle: {
id: 'layout.footer.developer-mode-deactivated.title',
defaultMessage: 'Developer mode deactivated',
},
developerModeDeactivatedText: {
id: 'layout.footer.developer-mode-deactivated.text',
defaultMessage: 'Developer mode has been disabled',
},
})
const socialLinks: {
@@ -215,14 +239,14 @@ function developerModeIncrement() {
saveFeatureFlags()
if (flags.value.developerMode) {
addNotification({
title: 'Developer mode activated',
text: 'Developer mode has been enabled',
title: formatMessage(messages.developerModeActivatedTitle),
text: formatMessage(messages.developerModeActivatedText),
type: 'success',
})
} else {
addNotification({
title: 'Developer mode deactivated',
text: 'Developer mode has been disabled',
title: formatMessage(messages.developerModeDeactivatedTitle),
text: formatMessage(messages.developerModeDeactivatedText),
type: 'success',
})
}
@@ -320,7 +344,7 @@ function developerModeIncrement() {
</div>
</div>
<p v-if="flags.developerMode" class="m-0 text-sm text-secondary">
Based on
{{ formatMessage(messages.basedOnLabel) }}
<a
v-if="config.public.owner && config.public.branch"
class="hover:underline"
@@ -330,7 +354,9 @@ function developerModeIncrement() {
{{ config.public.owner }}/{{ config.public.branch }}
</a>
@
<span v-if="config.public.hash === 'unknown'">unknown</span>
<span v-if="config.public.hash === 'unknown'">{{
formatMessage(messages.unknownCommit)
}}</span>
<AutoLink
v-else
class="text-link"
@@ -1,6 +1,11 @@
<template>
<div>
<NewModal ref="modalOpen" header="Transfer projects" no-padding width="60rem">
<NewModal
ref="modalOpen"
:header="formatMessage(messages.transferProjectsTitle)"
no-padding
width="60rem"
>
<div class="max-h-[70vh] overflow-y-auto">
<Table
class="!rounded-none !border-0"
@@ -12,7 +17,7 @@
>
<template #empty-state>
<div class="flex h-48 items-center justify-center text-secondary">
No projects available to transfer.
{{ formatMessage(messages.noProjectsAvailable) }}
</div>
</template>
<template #header-select>
@@ -38,7 +43,7 @@
<img
v-if="project.icon_url"
:src="project.icon_url"
:alt="'Icon for ' + project.title"
:alt="formatMessage(messages.projectIconAlt, { name: project.title })"
class="h-full w-full rounded object-cover"
/>
<BoxIcon v-else class="h-full w-full" />
@@ -69,8 +74,11 @@
<ButtonStyled color="brand">
<button :disabled="selectedProjects.length === 0" @click="submitTransfer()">
<TransferIcon />
Transfer {{ selectedProjects.length }}
{{ selectedProjects.length === 1 ? 'project' : 'projects' }}
{{
formatMessage(messages.transferSelectedProjects, {
count: selectedProjects.length,
})
}}
</button>
</ButtonStyled>
</div>
@@ -79,7 +87,7 @@
<ButtonStyled>
<button @click="show($event)">
<TransferIcon />
<span>Transfer projects</span>
<span>{{ formatMessage(messages.transferProjectsTitle) }}</span>
</button>
</ButtonStyled>
</div>
@@ -92,6 +100,7 @@ import {
Checkbox,
commonMessages,
CopyCode,
defineMessages,
NewModal,
Table,
useVIntl,
@@ -112,10 +121,41 @@ const props = defineProps({
const emit = defineEmits(['submit'])
const { formatMessage } = useVIntl()
const messages = defineMessages({
transferProjectsTitle: {
id: 'organization.project-transfer.title',
defaultMessage: 'Transfer projects',
},
noProjectsAvailable: {
id: 'organization.project-transfer.no-projects-available',
defaultMessage: 'No projects available to transfer.',
},
projectIconAlt: {
id: 'organization.project-transfer.project-icon-alt',
defaultMessage: 'Icon for {name}',
},
transferSelectedProjects: {
id: 'organization.project-transfer.transfer-selected-projects',
defaultMessage: 'Transfer {count, plural, one {# project} other {# projects}}',
},
nameColumn: {
id: 'organization.project-transfer.name-column',
defaultMessage: 'Name',
},
idColumn: {
id: 'organization.project-transfer.id-column',
defaultMessage: 'ID',
},
typeColumn: {
id: 'organization.project-transfer.type-column',
defaultMessage: 'Type',
},
})
const modalOpen = ref(null)
const selectedProjectIds = ref([])
const projectTableColumns = [
const projectTableColumns = computed(() => [
{
key: 'select',
width: '3rem',
@@ -124,21 +164,21 @@ const projectTableColumns = [
},
{
key: 'name',
label: 'Name',
label: formatMessage(messages.nameColumn),
width: '22rem',
},
{
key: 'id',
label: 'ID',
label: formatMessage(messages.idColumn),
width: '13rem',
cellClass: '!overflow-visible',
},
{
key: 'type',
label: 'Type',
label: formatMessage(messages.typeColumn),
width: '10rem',
},
]
])
const transferableProjects = computed(() =>
props.projects.filter((project) => !isProjectTransferDisabled(project)),
@@ -32,7 +32,7 @@
@update:selection="updateProjectDownloadSelection"
@download="onDownload"
/>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 empty:hidden">
<DownloadDependencies @download="onDownload" />
</div>
<ServersPromo
@@ -11,19 +11,21 @@
v-if="handlingNewFiles || !(filesToAdd.length || draftVersion.existing_files?.length)"
>
<DropzoneFileInput
aria-label="Upload file"
:aria-label="formatMessage(messages.uploadFileAriaLabel)"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
primary-prompt="Upload primary and supporting files"
secondary-prompt="Drag and drop files or click to browse"
:primary-prompt="formatMessage(messages.uploadPrimaryPrompt)"
:secondary-prompt="formatMessage(messages.uploadSecondaryPrompt)"
@change="handleNewFiles"
/>
</template>
<template v-else>
<div class="flex flex-col gap-2">
<span class="text-base font-semibold text-contrast">Primary file</span>
<span class="text-base font-semibold text-contrast">{{
formatMessage(messages.primaryFileLabel)
}}</span>
<div class="flex flex-col gap-2.5">
<VersionFileRow
v-if="primaryFile"
@@ -36,7 +38,7 @@
/>
</div>
<span>
The primary file is the default file a user downloads when installing the project.
{{ formatMessage(messages.primaryFileDescription) }}
</span>
</div>
@@ -46,16 +48,18 @@
{{ formatMessage(messages.addFilesAdmonition) }}
</Admonition>
<span class="text-base font-semibold text-contrast">Supplementary files</span>
<span class="text-base font-semibold text-contrast">{{
formatMessage(messages.supplementaryFilesLabel)
}}</span>
<DropzoneFileInput
aria-label="Upload additional file"
:aria-label="formatMessage(messages.uploadAdditionalFileAriaLabel)"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
size="small"
:primary-prompt="null"
secondary-prompt="Drag and drop files or click to browse"
:secondary-prompt="formatMessage(messages.uploadSecondaryPrompt)"
@change="handleNewFiles"
/>
@@ -84,8 +88,7 @@
</div>
</div>
<span>
You can optionally add supplementary files such as source code, documentation, or required
resource packs.
{{ formatMessage(messages.supplementaryFilesDescription) }}
</span>
</div>
</template>
@@ -124,11 +127,11 @@ const {
handleNewFiles,
} = injectManageVersionContext()
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const editTabs = computed<TabsTab[]>(() => [
{ label: formatMessage(messages.metadataTab), value: 'metadata' },
{ label: formatMessage(messages.detailsTab), value: 'add-details' },
{ label: formatMessage(messages.filesTab), value: 'add-files' },
])
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
@@ -171,5 +174,51 @@ const messages = defineMessages({
defaultMessage:
'Supplementary files are for supporting resources like source code, not for alternative versions or variants.',
},
uploadFileAriaLabel: {
id: 'create-project-version.create-modal.stage.add-files.upload-file-aria-label',
defaultMessage: 'Upload file',
},
uploadAdditionalFileAriaLabel: {
id: 'create-project-version.create-modal.stage.add-files.upload-additional-file-aria-label',
defaultMessage: 'Upload additional file',
},
uploadPrimaryPrompt: {
id: 'create-project-version.create-modal.stage.add-files.upload-primary-prompt',
defaultMessage: 'Upload primary and supporting files',
},
uploadSecondaryPrompt: {
id: 'create-project-version.create-modal.stage.add-files.upload-secondary-prompt',
defaultMessage: 'Drag and drop files or click to browse',
},
primaryFileLabel: {
id: 'create-project-version.create-modal.stage.add-files.primary-file-label',
defaultMessage: 'Primary file',
},
primaryFileDescription: {
id: 'create-project-version.create-modal.stage.add-files.primary-file-description',
defaultMessage:
'The primary file is the default file a user downloads when installing the project.',
},
supplementaryFilesLabel: {
id: 'create-project-version.create-modal.stage.add-files.supplementary-files-label',
defaultMessage: 'Supplementary files',
},
supplementaryFilesDescription: {
id: 'create-project-version.create-modal.stage.add-files.supplementary-files-description',
defaultMessage:
'You can optionally add supplementary files such as source code, documentation, or required resource packs.',
},
metadataTab: {
id: 'create-project-version.create-modal.stage.add-files.metadata-tab',
defaultMessage: 'Metadata',
},
detailsTab: {
id: 'create-project-version.create-modal.stage.add-files.details-tab',
defaultMessage: 'Details',
},
filesTab: {
id: 'create-project-version.create-modal.stage.add-files.files-tab',
defaultMessage: 'Files',
},
})
</script>
@@ -9,7 +9,7 @@
<div class="flex w-full flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version type <span class="text-red">*</span>
{{ formatMessage(messages.versionType) }} <span class="text-red">*</span>
</span>
<Chips
v-model="draftVersion.version_type"
@@ -22,31 +22,33 @@
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version number <span class="text-red">*</span>
{{ formatMessage(messages.versionNumber) }} <span class="text-red">*</span>
</span>
<StyledInput
id="version-number"
v-model="draftVersion.version_number"
:disabled="isUploading"
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
autocomplete="off"
:maxlength="32"
/>
<span> The version number differentiates this specific version from others. </span>
<span>{{ formatMessage(messages.versionNumberDescription) }}</span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version subtitle </span>
<span class="font-semibold text-contrast">{{ formatMessage(messages.versionSubtitle) }}</span>
<StyledInput
id="version-number"
v-model="draftVersion.name"
placeholder="Enter subtitle..."
:placeholder="formatMessage(messages.versionSubtitlePlaceholder)"
autocomplete="off"
:maxlength="256"
:disabled="isUploading"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changelog </span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.versionChangelog)
}}</span>
<div class="w-full">
<MarkdownEditor
@@ -61,18 +63,70 @@
</template>
<script lang="ts" setup>
import { Chips, MarkdownEditor, StyledInput, Tabs, type TabsTab } from '@modrinth/ui'
import {
Chips,
defineMessages,
MarkdownEditor,
StyledInput,
Tabs,
type TabsTab,
useVIntl,
} from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion, isUploading, editingVersion, modal } = injectManageVersionContext()
const { formatMessage } = useVIntl()
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const messages = defineMessages({
versionType: {
id: 'create-project-version.create-modal.stage.details.version-type',
defaultMessage: 'Version type',
},
versionNumber: {
id: 'create-project-version.create-modal.stage.details.version-number',
defaultMessage: 'Version number',
},
versionNumberPlaceholder: {
id: 'create-project-version.create-modal.stage.details.version-number-placeholder',
defaultMessage: 'Enter version number, e.g. 1.2.3-alpha.1',
},
versionNumberDescription: {
id: 'create-project-version.create-modal.stage.details.version-number-description',
defaultMessage: 'The version number differentiates this specific version from others.',
},
versionSubtitle: {
id: 'create-project-version.create-modal.stage.details.version-subtitle',
defaultMessage: 'Version subtitle',
},
versionSubtitlePlaceholder: {
id: 'create-project-version.create-modal.stage.details.version-subtitle-placeholder',
defaultMessage: 'Enter subtitle...',
},
versionChangelog: {
id: 'create-project-version.create-modal.stage.details.version-changelog',
defaultMessage: 'Version changelog',
},
metadataTab: {
id: 'create-project-version.create-modal.stage.details.metadata-tab',
defaultMessage: 'Metadata',
},
detailsTab: {
id: 'create-project-version.create-modal.stage.details.details-tab',
defaultMessage: 'Details',
},
filesTab: {
id: 'create-project-version.create-modal.stage.details.files-tab',
defaultMessage: 'Files',
},
})
const editTabs = computed<TabsTab[]>(() => [
{ label: formatMessage(messages.metadataTab), value: 'metadata' },
{ label: formatMessage(messages.detailsTab), value: 'add-details' },
{ label: formatMessage(messages.filesTab), value: 'add-files' },
])
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
@@ -9,12 +9,12 @@
<div class="flex flex-col gap-6">
<div v-if="!editingVersion" class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Uploaded files </span>
<span class="font-semibold text-contrast">{{ formatMessage(messages.uploadedFiles) }}</span>
<ButtonStyled type="transparent" size="standard">
<button @click="editFiles">
<EditIcon />
Edit
{{ formatMessage(messages.editButton) }}
</button>
</ButtonStyled>
</div>
@@ -43,23 +43,27 @@
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
{{
usingDetectedLoaders
? formatMessage(messages.detectedLoaders)
: formatMessage(messages.loaders)
}}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="
isModpack
? 'Modpack loaders cannot be edited'
? formatMessage(messages.modpackLoadersTooltip)
: isResourcePack
? 'Resource pack loaders cannot be edited'
? formatMessage(messages.resourcePackLoadersTooltip)
: undefined
"
:disabled="isModpack || isResourcePack"
@click="editLoaders"
>
<EditIcon />
Edit
{{ formatMessage(messages.editButton) }}
</button>
</ButtonStyled>
</div>
@@ -88,9 +92,11 @@
v-if="!draftVersionLoaders.length && projectType === 'modpack'"
class="border !border-solid border-surface-5 hover:no-underline"
>
No mod loader
{{ formatMessage(messages.noModLoader) }}
</TagItem>
<span v-else-if="!draftVersionLoaders.length">No loaders selected.</span>
<span v-else-if="!draftVersionLoaders.length">{{
formatMessage(messages.noLoadersSelected)
}}</span>
</div>
</div>
</div>
@@ -98,17 +104,21 @@
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
{{
usingDetectedVersions
? formatMessage(messages.detectedVersions)
: formatMessage(messages.versions)
}}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
v-tooltip="isModpack ? formatMessage(messages.modpackVersionsTooltip) : undefined"
:disabled="isModpack"
@click="editVersions"
>
<EditIcon />
Edit
{{ formatMessage(messages.editButton) }}
</button>
</ButtonStyled>
</div>
@@ -125,7 +135,9 @@
{{ version }}
</TagItem>
<span v-if="!draftVersion.game_versions.length">No versions selected.</span>
<span v-if="!draftVersion.game_versions.length">{{
formatMessage(messages.noVersionsSelected)
}}</span>
</div>
</div>
</div>
@@ -134,14 +146,16 @@
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-contrast"> Environment </span>
<UnknownIcon v-tooltip="'Pre-filled from a previous similar version'" />
<span class="font-semibold text-contrast">{{
formatMessage(messages.environment)
}}</span>
<UnknownIcon v-tooltip="formatMessage(messages.prefilledEnvironmentTooltip)" />
</div>
<ButtonStyled type="transparent" size="standard">
<button @click="editEnvironment">
<EditIcon />
Edit
{{ formatMessage(messages.editButton) }}
</button>
</ButtonStyled>
</div>
@@ -154,7 +168,9 @@
<div class="text-sm font-medium">{{ environmentCopy.description }}</div>
</div>
<span v-else class="text-sm font-medium">No environment has been set.</span>
<span v-else class="text-sm font-medium">{{
formatMessage(messages.noEnvironmentSet)
}}</span>
</div>
</div>
</template>
@@ -163,12 +179,14 @@
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Dependencies </span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.dependencies)
}}</span>
<ButtonStyled type="transparent" size="standard">
<button @click="addDependency">
<PlusIcon />
Add dependency
{{ formatMessage(messages.addDependency) }}
</button>
</ButtonStyled>
</div>
@@ -177,13 +195,15 @@
<DependenciesList />
</div>
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<span class="text-sm font-medium">No dependencies added.</span>
<span class="text-sm font-medium">{{
formatMessage(messages.noDependenciesAdded)
}}</span>
</div>
</div>
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="font-medium"> Suggested </span>
<span class="font-medium">{{ formatMessage(messages.suggested) }}</span>
</div>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
@@ -231,11 +251,11 @@ const { projectV2 } = injectProjectPageContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const editTabs = computed<TabsTab[]>(() => [
{ label: formatMessage(messages.metadataTab), value: 'metadata' },
{ label: formatMessage(messages.detailsTab), value: 'add-details' },
{ label: formatMessage(messages.filesTab), value: 'add-files' },
])
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
@@ -341,6 +361,97 @@ const supplementaryExistingFiles = computed(() => {
const { formatMessage } = useVIntl()
const messages = defineMessages({
uploadedFiles: {
id: 'create-project-version.create-modal.stage.metadata.uploaded-files',
defaultMessage: 'Uploaded files',
},
editButton: {
id: 'create-project-version.create-modal.stage.metadata.edit-button',
defaultMessage: 'Edit',
},
detectedLoaders: {
id: 'create-project-version.create-modal.stage.metadata.detected-loaders',
defaultMessage: 'Detected loaders',
},
loaders: {
id: 'create-project-version.create-modal.stage.metadata.loaders',
defaultMessage: 'Loaders',
},
modpackLoadersTooltip: {
id: 'create-project-version.create-modal.stage.metadata.modpack-loaders-tooltip',
defaultMessage: 'Modpack loaders cannot be edited',
},
resourcePackLoadersTooltip: {
id: 'create-project-version.create-modal.stage.metadata.resource-pack-loaders-tooltip',
defaultMessage: 'Resource pack loaders cannot be edited',
},
noModLoader: {
id: 'create-project-version.create-modal.stage.metadata.no-mod-loader',
defaultMessage: 'No mod loader',
},
noLoadersSelected: {
id: 'create-project-version.create-modal.stage.metadata.no-loaders-selected',
defaultMessage: 'No loaders selected.',
},
detectedVersions: {
id: 'create-project-version.create-modal.stage.metadata.detected-versions',
defaultMessage: 'Detected versions',
},
versions: {
id: 'create-project-version.create-modal.stage.metadata.versions',
defaultMessage: 'Versions',
},
modpackVersionsTooltip: {
id: 'create-project-version.create-modal.stage.metadata.modpack-versions-tooltip',
defaultMessage: 'Modpack versions cannot be edited',
},
noVersionsSelected: {
id: 'create-project-version.create-modal.stage.metadata.no-versions-selected',
defaultMessage: 'No versions selected.',
},
environment: {
id: 'create-project-version.create-modal.stage.metadata.environment',
defaultMessage: 'Environment',
},
prefilledEnvironmentTooltip: {
id: 'create-project-version.create-modal.stage.metadata.prefilled-environment-tooltip',
defaultMessage: 'Pre-filled from a previous similar version',
},
noEnvironmentSet: {
id: 'create-project-version.create-modal.stage.metadata.no-environment-set',
defaultMessage: 'No environment has been set.',
},
dependencies: {
id: 'create-project-version.create-modal.stage.metadata.dependencies',
defaultMessage: 'Dependencies',
},
addDependency: {
id: 'create-project-version.create-modal.stage.metadata.add-dependency',
defaultMessage: 'Add dependency',
},
noDependenciesAdded: {
id: 'create-project-version.create-modal.stage.metadata.no-dependencies-added',
defaultMessage: 'No dependencies added.',
},
suggested: {
id: 'create-project-version.create-modal.stage.metadata.suggested',
defaultMessage: 'Suggested',
},
metadataTab: {
id: 'create-project-version.create-modal.stage.metadata.metadata-tab',
defaultMessage: 'Metadata',
},
detailsTab: {
id: 'create-project-version.create-modal.stage.metadata.details-tab',
defaultMessage: 'Details',
},
filesTab: {
id: 'create-project-version.create-modal.stage.metadata.files-tab',
defaultMessage: 'Files',
},
})
const noEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.none.title',
@@ -246,6 +246,18 @@ const messages = defineMessages({
id: 'create.project.missing-fields-tooltip',
defaultMessage: 'Missing fields: {fields}',
},
unknownUser: {
id: 'create.project.unknown-user',
defaultMessage: 'Unknown user',
},
userAvatarAlt: {
id: 'create.project.user-avatar-alt',
defaultMessage: 'User Avatar',
},
organizationIconAlt: {
id: 'create.project.organization-icon-alt',
defaultMessage: '{name} Icon',
},
})
const props = defineProps<{
@@ -318,7 +330,7 @@ const cancel = () => {
const userOption = computed(() => ({
value: 'self',
label: auth.value.user?.username || 'Unknown user',
label: auth.value.user?.username || formatMessage(messages.unknownUser),
icon: auth.value.user?.avatar_url
? markRaw(
defineAsyncComponent(() =>
@@ -326,7 +338,7 @@ const userOption = computed(() => ({
setup: () => () =>
h('img', {
src: auth.value.user?.avatar_url,
alt: 'User Avatar',
alt: formatMessage(messages.userAvatarAlt),
class: 'h-5 w-5 rounded-full',
}),
}),
@@ -357,7 +369,7 @@ async function fetchOrganizations() {
setup: () => () =>
h('img', {
src: org.icon_url,
alt: `${org.name} Icon`,
alt: formatMessage(messages.organizationIconAlt, { name: org.name }),
class: 'h-5 w-5 rounded',
}),
}),
@@ -137,7 +137,9 @@
</div>
<div class="flex w-full flex-row justify-stretch gap-2">
<ButtonStyled>
<button class="w-full text-contrast" @click="handleClose">{{ closeButtonText }}</button>
<button class="w-full text-contrast" @click="handleClose">
{{ props.closeButtonText ?? formatMessage(messages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="green">
<button class="w-full text-contrast" @click="downloadTaxForm">
@@ -182,7 +184,7 @@ const props = withDefaults(
emitSuccessOnClose?: boolean
}>(),
{
closeButtonText: 'Close',
closeButtonText: undefined,
emitSuccessOnClose: true,
},
)
@@ -268,6 +270,18 @@ const messages = defineMessages({
id: 'dashboard.creator-tax-form-modal.confirmation.download-button',
defaultMessage: 'Download {formType}',
},
closeButton: {
id: 'dashboard.creator-tax-form-modal.close-button',
defaultMessage: 'Close',
},
incompleteTitle: {
id: 'dashboard.creator-tax-form-modal.incomplete.title',
defaultMessage: 'Tax form incomplete',
},
incompleteText: {
id: 'dashboard.creator-tax-form-modal.incomplete.text',
defaultMessage: 'You have not completed the tax form. Please try again.',
},
})
const isUSCitizen = ref<'yes' | 'no' | null>(null)
@@ -371,8 +385,8 @@ async function continueForm() {
}
addNotification({
title: 'Tax form incomplete',
text: 'You have not completed the tax form. Please try again.',
title: formatMessage(messages.incompleteTitle),
text: formatMessage(messages.incompleteText),
type: 'warning',
})
}
@@ -54,7 +54,7 @@
<MuralpayDetailsStage v-else-if="currentStage === 'muralpay-details'" />
<LegacyPaypalDetailsStage v-else-if="currentStage === 'paypal-details'" />
<CompletionStage v-else-if="currentStage === 'completion'" />
<div v-else>Something went wrong</div>
<div v-else>{{ formatMessage(commonMessages.errorNotificationTitle) }}</div>
</div>
<template #actions>
<div v-if="currentStage === 'completion'" class="mt-4 flex w-full gap-3">
@@ -96,7 +96,7 @@
</NewModal>
<CreatorTaxFormModal
ref="taxFormModal"
close-button-text="Continue"
:close-button-text="formatMessage(commonMessages.continueButton)"
@success="onTaxFormSuccess"
@cancelled="onTaxFormCancelled"
/>
@@ -99,7 +99,7 @@
v-model="formData.dateOfBirth"
:max-date="maxDate"
autocomplete="bday"
placeholder="Select date of birth"
:placeholder="formatMessage(messages.dateOfBirthPlaceholder)"
wrapper-class="w-full"
/>
</div>
@@ -162,7 +162,7 @@
:options="subdivisionOptions"
:placeholder="formatMessage(formFieldPlaceholders.statePlaceholder)"
searchable
search-placeholder="Search subdivisions..."
:search-placeholder="formatMessage(messages.searchSubdivisionsPlaceholder)"
/>
<StyledInput
v-else
@@ -201,7 +201,7 @@
:options="countryOptions"
:placeholder="formatMessage(formFieldPlaceholders.countryPlaceholder)"
searchable
search-placeholder="Search countries..."
:search-placeholder="formatMessage(messages.searchCountriesPlaceholder)"
/>
</div>
</div>
@@ -361,5 +361,17 @@ const messages = defineMessages({
id: 'dashboard.creator-withdraw-modal.kyc.business-entity',
defaultMessage: 'Business entity',
},
dateOfBirthPlaceholder: {
id: 'dashboard.creator-withdraw-modal.kyc.date-of-birth-placeholder',
defaultMessage: 'Select date of birth',
},
searchSubdivisionsPlaceholder: {
id: 'dashboard.creator-withdraw-modal.kyc.search-subdivisions-placeholder',
defaultMessage: 'Search subdivisions...',
},
searchCountriesPlaceholder: {
id: 'dashboard.creator-withdraw-modal.kyc.search-countries-placeholder',
defaultMessage: 'Search countries...',
},
})
</script>
@@ -58,9 +58,17 @@
<Combobox
v-model="selectedGiftCardId"
:options="rewardOptions"
:placeholder="`Select ${categoryLabel.toLowerCase()}`"
:placeholder="
formatMessage(messages.selectCategoryPlaceholder, {
category: categoryLabel.toLowerCase(),
})
"
searchable
:search-placeholder="`Search ${categoryLabelPlural.toLowerCase()}...`"
:search-placeholder="
formatMessage(messages.searchCategoryPlaceholder, {
category: categoryLabelPlural.toLowerCase(),
})
"
class="h-10"
>
<template #selected>
@@ -90,47 +98,21 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{ formatMoney(displayMinUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
displayMinLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
min<template v-if="displayMinUsd <= roundedMaxAmount"
>, {{ formatMoney(displayMaxUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
displayMaxLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
max</template
>
withdrawal amount.
<template v-if="displayMinUsd <= roundedMaxAmount">{{
formatMessage(messages.withdrawalAmountMinMax, {
min: minAmountDisplay,
max: maxAmountDisplay,
})
}}</template>
<template v-else>{{
formatMessage(messages.withdrawalAmountMinOnly, { min: minAmountDisplay })
}}</template>
</span>
<span
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
You need at least
{{ formatMoney(displayMinUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
displayMinLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
to use this gift card.
{{ formatMessage(messages.needAtLeastGiftCard, { min: minAmountDisplay }) }}
</span>
</div>
@@ -280,19 +262,13 @@
v-if="!useDenominationSuggestions && denominationOptions.length === 0"
class="text-error text-sm"
>
<template v-if="rawFixedDenominationMin !== null">
The minimum denomination is
{{
formatAmountForDisplay(
rawFixedDenominationMin,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{ formatMoney(convertToUsd(rawFixedDenominationMin)) }})</template
>, which exceeds your balance of {{ formatMoney(roundedMaxAmount) }}.
</template>
<template v-else>No denominations available for your current balance</template>
<template v-if="rawFixedDenominationMin !== null">{{
formatMessage(messages.minimumDenominationExceedsBalance, {
min: minDenominationDisplay,
balance: formatMoney(roundedMaxAmount),
})
}}</template>
<template v-else>{{ formatMessage(messages.noDenominationsAvailable) }}</template>
</span>
</div>
@@ -683,13 +659,50 @@ const noSuggestionsMessage = computed(() => {
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)
return `No denominations near this amount. The highest available is ${maxInLocal}.`
return formatMessage(messages.noSuggestionsWithMax, { max: maxInLocal })
}
return 'No denominations near this amount'
return formatMessage(messages.noSuggestionsNoMax)
}
return null
})
const minAmountDisplay = computed(() => {
const base = formatMoney(displayMinUsd.value)
if (selectedMethodCurrencyCode.value && selectedMethodCurrencyCode.value !== 'USD') {
return `${base} (${formatAmountForDisplay(
displayMinLocal.value,
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)})`
}
return base
})
const maxAmountDisplay = computed(() => {
const base = formatMoney(displayMaxUsd.value)
if (selectedMethodCurrencyCode.value && selectedMethodCurrencyCode.value !== 'USD') {
return `${base} (${formatAmountForDisplay(
displayMaxLocal.value,
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)})`
}
return base
})
const minDenominationDisplay = computed(() => {
if (rawFixedDenominationMin.value === null) return ''
const local = formatAmountForDisplay(
rawFixedDenominationMin.value,
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)
if (selectedMethodCurrencyCode.value && selectedMethodCurrencyCode.value !== 'USD') {
return `${local} (${formatMoney(convertToUsd(rawFixedDenominationMin.value))})`
}
return local
})
const hasSelectedDenomination = computed(() => {
return (
formData.value.amount !== undefined &&
@@ -1124,5 +1137,41 @@ const messages = defineMessages({
id: 'dashboard.creator-withdraw-modal.tremendous-details.select-denomination-required',
defaultMessage: 'Please select a denomination to continue',
},
selectCategoryPlaceholder: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.select-category-placeholder',
defaultMessage: 'Select {category}',
},
searchCategoryPlaceholder: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.search-category-placeholder',
defaultMessage: 'Search {category}...',
},
withdrawalAmountMinMax: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.withdrawal-amount-min-max',
defaultMessage: '{min} min, {max} max withdrawal amount.',
},
withdrawalAmountMinOnly: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.withdrawal-amount-min-only',
defaultMessage: '{min} min withdrawal amount.',
},
needAtLeastGiftCard: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.need-at-least-gift-card',
defaultMessage: 'You need at least {min} to use this gift card.',
},
minimumDenominationExceedsBalance: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.minimum-denomination-exceeds-balance',
defaultMessage: 'The minimum denomination is {min}, which exceeds your balance of {balance}.',
},
noDenominationsAvailable: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.no-denominations-available',
defaultMessage: 'No denominations available for your current balance',
},
noSuggestionsWithMax: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.no-suggestions-with-max',
defaultMessage: 'No denominations near this amount. The highest available is {max}.',
},
noSuggestionsNoMax: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.no-suggestions-no-max',
defaultMessage: 'No denominations near this amount',
},
})
</script>
@@ -27,10 +27,14 @@ const messages = defineMessages({
id: 'hosting.plan.select-plan',
defaultMessage: 'Select plan',
},
billedInterval: {
id: 'servers.purchase.step.plan.billed',
perMonthBilling: {
id: 'servers.purchase.step.plan.per-month-billing',
defaultMessage:
'billed {interval, select, monthly {monthly} quarterly {quarterly} yearly {yearly} other {{interval}}}',
'/ month{interval, select, monthly {} other {, billed {interval, select, quarterly {quarterly} yearly {yearly} other {{interval}}}}}',
},
mostPopularBadge: {
id: 'servers.purchase.step.plan.most-popular',
defaultMessage: 'Most popular',
},
})
@@ -131,15 +135,13 @@ const billingMonths = computed(() => {
v-if="plans[plan].mostPopular"
class="rounded-full bg-brand-highlight px-2 py-1 text-xs font-bold text-brand"
>
Most popular
{{ formatMessage(messages.mostPopularBadge) }}
</div>
</div>
<span class="m-0 text-2xl font-bold text-contrast">
{{ formatPrice(price / billingMonths, currency, true) }}
<span class="text-lg font-semibold text-secondary">
/ month<template v-if="interval !== 'monthly'"
>, {{ formatMessage(messages.billedInterval, { interval }) }}</template
>
{{ formatMessage(messages.perMonthBilling, { interval }) }}
</span>
</span>
<p class="m-0 max-w-[18rem]">{{ formatMessage(plans[plan].description) }}</p>
@@ -4067,9 +4067,6 @@
"servers.plan.small.description": {
"message": "Perfekt für 1 - 5 Freunde mit ein par leichten Mods."
},
"servers.purchase.step.plan.billed": {
"message": "abgerechnet {interval, select, monthly {monthly} quarterly {quarterly} yearly {yearly} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Einrichtung abschliessen"
},
@@ -4067,9 +4067,6 @@
"servers.plan.small.description": {
"message": "Perfekt für 15 Freunde mit ein paar kleinen Mods."
},
"servers.purchase.step.plan.billed": {
"message": "{interval, select, monthly {monatlich} quarterly {vierteljährlich} yearly {jährlich} other {{interval}}} abgerechnet"
},
"settings.account.button.complete-setup": {
"message": "Einrichtung abschließen"
},
+413 -2
View File
@@ -251,6 +251,9 @@
"analytics.empty.select-project": {
"message": "Select at least one project to view data"
},
"analytics.filter.dependent-project-search-failed-title": {
"message": "Dependent projects failed to load"
},
"analytics.filter.game-version-type": {
"message": "Game version type"
},
@@ -947,6 +950,12 @@
"auth.verify-email.title": {
"message": "Verify Email"
},
"collection.aria-label.upload-icon": {
"message": "Upload icon"
},
"collection.button.discover-mods": {
"message": "Discover mods"
},
"collection.button.edit-icon": {
"message": "Edit icon"
},
@@ -1121,6 +1130,135 @@
"create-project-version.create-modal.stage.add-files.admonition": {
"message": "Supplementary files are for supporting resources like source code, not for alternative versions or variants."
},
"create-project-version.create-modal.stage.add-files.details-tab": {
"message": "Details"
},
"create-project-version.create-modal.stage.add-files.files-tab": {
"message": "Files"
},
"create-project-version.create-modal.stage.add-files.metadata-tab": {
"message": "Metadata"
},
"create-project-version.create-modal.stage.add-files.primary-file-description": {
"message": "The primary file is the default file a user downloads when installing the project."
},
"create-project-version.create-modal.stage.add-files.primary-file-label": {
"message": "Primary file"
},
"create-project-version.create-modal.stage.add-files.supplementary-files-description": {
"message": "You can optionally add supplementary files such as source code, documentation, or required resource packs."
},
"create-project-version.create-modal.stage.add-files.supplementary-files-label": {
"message": "Supplementary files"
},
"create-project-version.create-modal.stage.add-files.upload-additional-file-aria-label": {
"message": "Upload additional file"
},
"create-project-version.create-modal.stage.add-files.upload-file-aria-label": {
"message": "Upload file"
},
"create-project-version.create-modal.stage.add-files.upload-primary-prompt": {
"message": "Upload primary and supporting files"
},
"create-project-version.create-modal.stage.add-files.upload-secondary-prompt": {
"message": "Drag and drop files or click to browse"
},
"create-project-version.create-modal.stage.details.details-tab": {
"message": "Details"
},
"create-project-version.create-modal.stage.details.files-tab": {
"message": "Files"
},
"create-project-version.create-modal.stage.details.metadata-tab": {
"message": "Metadata"
},
"create-project-version.create-modal.stage.details.version-changelog": {
"message": "Version changelog"
},
"create-project-version.create-modal.stage.details.version-number": {
"message": "Version number"
},
"create-project-version.create-modal.stage.details.version-number-description": {
"message": "The version number differentiates this specific version from others."
},
"create-project-version.create-modal.stage.details.version-number-placeholder": {
"message": "Enter version number, e.g. 1.2.3-alpha.1"
},
"create-project-version.create-modal.stage.details.version-subtitle": {
"message": "Version subtitle"
},
"create-project-version.create-modal.stage.details.version-subtitle-placeholder": {
"message": "Enter subtitle..."
},
"create-project-version.create-modal.stage.details.version-type": {
"message": "Version type"
},
"create-project-version.create-modal.stage.metadata.add-dependency": {
"message": "Add dependency"
},
"create-project-version.create-modal.stage.metadata.dependencies": {
"message": "Dependencies"
},
"create-project-version.create-modal.stage.metadata.details-tab": {
"message": "Details"
},
"create-project-version.create-modal.stage.metadata.detected-loaders": {
"message": "Detected loaders"
},
"create-project-version.create-modal.stage.metadata.detected-versions": {
"message": "Detected versions"
},
"create-project-version.create-modal.stage.metadata.edit-button": {
"message": "Edit"
},
"create-project-version.create-modal.stage.metadata.environment": {
"message": "Environment"
},
"create-project-version.create-modal.stage.metadata.files-tab": {
"message": "Files"
},
"create-project-version.create-modal.stage.metadata.loaders": {
"message": "Loaders"
},
"create-project-version.create-modal.stage.metadata.metadata-tab": {
"message": "Metadata"
},
"create-project-version.create-modal.stage.metadata.modpack-loaders-tooltip": {
"message": "Modpack loaders cannot be edited"
},
"create-project-version.create-modal.stage.metadata.modpack-versions-tooltip": {
"message": "Modpack versions cannot be edited"
},
"create-project-version.create-modal.stage.metadata.no-dependencies-added": {
"message": "No dependencies added."
},
"create-project-version.create-modal.stage.metadata.no-environment-set": {
"message": "No environment has been set."
},
"create-project-version.create-modal.stage.metadata.no-loaders-selected": {
"message": "No loaders selected."
},
"create-project-version.create-modal.stage.metadata.no-mod-loader": {
"message": "No mod loader"
},
"create-project-version.create-modal.stage.metadata.no-versions-selected": {
"message": "No versions selected."
},
"create-project-version.create-modal.stage.metadata.prefilled-environment-tooltip": {
"message": "Pre-filled from a previous similar version"
},
"create-project-version.create-modal.stage.metadata.resource-pack-loaders-tooltip": {
"message": "Resource pack loaders cannot be edited"
},
"create-project-version.create-modal.stage.metadata.suggested": {
"message": "Suggested"
},
"create-project-version.create-modal.stage.metadata.uploaded-files": {
"message": "Uploaded files"
},
"create-project-version.create-modal.stage.metadata.versions": {
"message": "Versions"
},
"create.collection.collection-info": {
"message": "Your new collection will be created as a public collection with {count, plural, =0 {no projects} one {# project} other {# projects}}."
},
@@ -1217,6 +1355,9 @@
"create.project.name-placeholder": {
"message": "Enter project name..."
},
"create.project.organization-icon-alt": {
"message": "{name} Icon"
},
"create.project.owner-description": {
"message": "Set the project owner as yourself or an organization you're a member of."
},
@@ -1247,9 +1388,15 @@
"create.project.type-server": {
"message": "Server"
},
"create.project.unknown-user": {
"message": "Unknown user"
},
"create.project.url-label": {
"message": "URL"
},
"create.project.user-avatar-alt": {
"message": "User Avatar"
},
"create.project.visibility-description": {
"message": "The visibility of your project after it has been approved."
},
@@ -1331,6 +1478,9 @@
"dashboard.collections.sort.recently-updated": {
"message": "Recently Updated"
},
"dashboard.creator-tax-form-modal.close-button": {
"message": "Close"
},
"dashboard.creator-tax-form-modal.confirmation.download-button": {
"message": "Download {formType}"
},
@@ -1358,6 +1508,12 @@
"dashboard.creator-tax-form-modal.header": {
"message": "Tax form"
},
"dashboard.creator-tax-form-modal.incomplete.text": {
"message": "You have not completed the tax form. Please try again."
},
"dashboard.creator-tax-form-modal.incomplete.title": {
"message": "Tax form incomplete"
},
"dashboard.creator-tax-form-modal.security.description": {
"message": "Modrinth uses third-party provider Track1099 to securely collect and store your tax forms. <security-link>Learn more here.</security-link>"
},
@@ -1397,6 +1553,9 @@
"dashboard.creator-withdraw-modal.kyc.business-entity": {
"message": "Business entity"
},
"dashboard.creator-withdraw-modal.kyc.date-of-birth-placeholder": {
"message": "Select date of birth"
},
"dashboard.creator-withdraw-modal.kyc.entity-description": {
"message": "A business entity refers to a registered organization such as a corporation, partnership, or LLC."
},
@@ -1406,6 +1565,12 @@
"dashboard.creator-withdraw-modal.kyc.private-individual": {
"message": "Private individual"
},
"dashboard.creator-withdraw-modal.kyc.search-countries-placeholder": {
"message": "Search countries..."
},
"dashboard.creator-withdraw-modal.kyc.search-subdivisions-placeholder": {
"message": "Search subdivisions..."
},
"dashboard.creator-withdraw-modal.method-selection.country-placeholder": {
"message": "Select your country"
},
@@ -1541,6 +1706,21 @@
"dashboard.creator-withdraw-modal.tremendous-details.enter-denomination-placeholder": {
"message": "Enter amount"
},
"dashboard.creator-withdraw-modal.tremendous-details.minimum-denomination-exceeds-balance": {
"message": "The minimum denomination is {min}, which exceeds your balance of {balance}."
},
"dashboard.creator-withdraw-modal.tremendous-details.need-at-least-gift-card": {
"message": "You need at least {min} to use this gift card."
},
"dashboard.creator-withdraw-modal.tremendous-details.no-denominations-available": {
"message": "No denominations available for your current balance"
},
"dashboard.creator-withdraw-modal.tremendous-details.no-suggestions-no-max": {
"message": "No denominations near this amount"
},
"dashboard.creator-withdraw-modal.tremendous-details.no-suggestions-with-max": {
"message": "No denominations near this amount. The highest available is {max}."
},
"dashboard.creator-withdraw-modal.tremendous-details.payment-method": {
"message": "Payment method"
},
@@ -1556,6 +1736,12 @@
"dashboard.creator-withdraw-modal.tremendous-details.search-amount-label": {
"message": "Search amount"
},
"dashboard.creator-withdraw-modal.tremendous-details.search-category-placeholder": {
"message": "Search {category}..."
},
"dashboard.creator-withdraw-modal.tremendous-details.select-category-placeholder": {
"message": "Select {category}"
},
"dashboard.creator-withdraw-modal.tremendous-details.select-denomination-hint": {
"message": "Select a denomination:"
},
@@ -1574,6 +1760,12 @@
"dashboard.creator-withdraw-modal.tremendous-details.usd-paypal-warning-message": {
"message": "You selected USD for PayPal International. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> for better fees (≈2% instead of ≈6%)."
},
"dashboard.creator-withdraw-modal.tremendous-details.withdrawal-amount-min-max": {
"message": "{min} min, {max} max withdrawal amount."
},
"dashboard.creator-withdraw-modal.tremendous-details.withdrawal-amount-min-only": {
"message": "{min} min withdrawal amount."
},
"dashboard.creator-withdraw-modal.withdraw-button": {
"message": "Withdraw"
},
@@ -2237,6 +2429,24 @@
"hosting-marketing.medal.text-secondary": {
"message": "Limited-time offer. No credit card required. Available for US servers."
},
"hosting-marketing.notification.error-fetching-payment-data-title": {
"message": "Error fetching payment data"
},
"hosting-marketing.notification.invalid-product-text": {
"message": "The selected product was found but lacks necessary data. Please contact support."
},
"hosting-marketing.notification.invalid-product-title": {
"message": "Invalid product"
},
"hosting-marketing.notification.server-capacity-full-text": {
"message": "We are currently at capacity. Please try again later."
},
"hosting-marketing.notification.server-capacity-full-title": {
"message": "Server Capacity Full"
},
"hosting-marketing.notification.unexpected-error-text": {
"message": "An unexpected error occurred"
},
"hosting-marketing.pick-customized-plan": {
"message": "Pick a customized plan with just the specs you need."
},
@@ -2555,6 +2765,21 @@
"layout.footer.about.status": {
"message": "Status"
},
"layout.footer.based-on-label": {
"message": "Based on"
},
"layout.footer.developer-mode-activated.text": {
"message": "Developer mode has been enabled"
},
"layout.footer.developer-mode-activated.title": {
"message": "Developer mode activated"
},
"layout.footer.developer-mode-deactivated.text": {
"message": "Developer mode has been disabled"
},
"layout.footer.developer-mode-deactivated.title": {
"message": "Developer mode deactivated"
},
"layout.footer.legal": {
"message": "Legal"
},
@@ -2624,6 +2849,9 @@
"layout.footer.social.x": {
"message": "X"
},
"layout.footer.unknown-commit": {
"message": "unknown"
},
"layout.menu-toggle.action": {
"message": "Toggle menu"
},
@@ -3041,6 +3269,33 @@
"organization.label.projects": {
"message": "projects"
},
"organization.project-transfer.id-column": {
"message": "ID"
},
"organization.project-transfer.name-column": {
"message": "Name"
},
"organization.project-transfer.no-projects-available": {
"message": "No projects available to transfer."
},
"organization.project-transfer.project-icon-alt": {
"message": "Icon for {name}"
},
"organization.project-transfer.title": {
"message": "Transfer projects"
},
"organization.project-transfer.transfer-selected-projects": {
"message": "Transfer {count, plural, one {# project} other {# projects}}"
},
"organization.project-transfer.type-column": {
"message": "Type"
},
"organization.projects.none": {
"message": "This organization doesn't have any projects yet."
},
"organization.projects.none-with-create-prompt": {
"message": "This organization doesn't have any projects yet. Would you like to <create-link>create one</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "A Modrinth creator."
},
@@ -3476,6 +3731,9 @@
"project.gallery.title": {
"message": "Gallery"
},
"project.install-context.back-to-discover": {
"message": "Back to discover"
},
"project.license.error": {
"message": "License text could not be retrieved."
},
@@ -3695,6 +3953,102 @@
"project.settings.permissions.sort.status": {
"message": "Status"
},
"project.settings.server.bedrock-address-label": {
"message": "Bedrock address"
},
"project.settings.server.cannot-save-text": {
"message": "The Java server must be reachable before saving. Please ensure the ping succeeds."
},
"project.settings.server.cannot-save-title": {
"message": "Cannot save"
},
"project.settings.server.details-heading": {
"message": "Server details"
},
"project.settings.server.enter-address-placeholder": {
"message": "Enter address"
},
"project.settings.server.java-address-label": {
"message": "Java address"
},
"project.settings.server.languages-label": {
"message": "Languages"
},
"project.settings.server.latency-label": {
"message": "Latency: {latency}ms"
},
"project.settings.server.optional-label": {
"message": "optional"
},
"project.settings.server.ping-failed-message": {
"message": "We couldn't ping this server. It may be blocked by your host so try refreshing a few times. If it still doesn't respond please <support-link>contact support</support-link>."
},
"project.settings.server.refresh-ping-tooltip": {
"message": "Refresh ping"
},
"project.settings.server.region-label": {
"message": "Region"
},
"project.settings.server.select-languages-placeholder": {
"message": "Select languages"
},
"project.settings.server.select-region-placeholder": {
"message": "Select region"
},
"project.settings.server.server-online": {
"message": "Server is online!"
},
"project.settings.server.srv-records-hint": {
"message": "If you have <srv-tooltip>[SRV records]</srv-tooltip>, you do not need to add a port. Otherwise if you have a port which isn't 25565, you can include it as :12345"
},
"project.settings.server.srv-records-tooltip": {
"message": "The address you enter here may have DNS SRV records _minecraft._tcp.(your domain) which point to your Minecraft server address and port."
},
"project.settings.tags.all-tags-selected-warning": {
"message": "You've selected all {count} available tags. Please select only the tags that truly apply to your project."
},
"project.settings.tags.categories-description": {
"message": "Select all categories that reflect the themes or function of your {type}."
},
"project.settings.tags.featured-tags": {
"message": "Featured tags"
},
"project.settings.tags.featured-tags-description": {
"message": "You can feature up to 3 of your most relevant tags. Other tags may be promoted to featured if you do not select all 3."
},
"project.settings.tags.features-description": {
"message": "Select all of the features that your {type} makes use of."
},
"project.settings.tags.multiple-resolution-tags-warning": {
"message": "You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag."
},
"project.settings.tags.performance-impact-description": {
"message": "Select the realistic performance impact of your {type}. Select multiple if the {type} is configurable to different levels of performance impact."
},
"project.settings.tags.resolutions-description": {
"message": "Select the resolution(s) of textures in your {type}."
},
"project.settings.tags.select-at-least-one-category": {
"message": "Select at least one category in order to feature a category."
},
"project.settings.tags.tagging-importance-description": {
"message": "Accurate tagging is important to help people find your {type}. Make sure to select all tags that apply."
},
"project.settings.tags.title": {
"message": "Tags"
},
"project.settings.tags.too-many-tags-project-warning": {
"message": "You've selected {count} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover."
},
"project.settings.tags.too-many-tags-server-hard-warning": {
"message": "You've selected {count} tags. Please reduce to 18 or fewer to keep your server focused and easier to discover."
},
"project.settings.tags.too-many-tags-server-soft-warning": {
"message": "You've selected {count} tags. Consider reducing to 12 or fewer to keep your server focused and easier to discover."
},
"project.settings.tags.upload-version-first": {
"message": "Please upload a version first in order to select tags!"
},
"project.settings.title": {
"message": "Settings"
},
@@ -3704,9 +4058,51 @@
"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"
},
"project.versions.copy-link-option": {
"message": "Copy link"
},
"project.versions.copy-maven-coordinates-option": {
"message": "Copy Maven coordinates"
},
"project.versions.delete-button": {
"message": "Delete"
},
"project.versions.delete-confirm.description": {
"message": "This will remove this version forever (like really forever)."
},
"project.versions.delete-confirm.title": {
"message": "Are you sure you want to delete this version?"
},
"project.versions.edit-details-option": {
"message": "Edit details"
},
"project.versions.edit-files-option": {
"message": "Edit files"
},
"project.versions.edit-metadata-option": {
"message": "Edit metadata"
},
"project.versions.edit-version-tooltip": {
"message": "Edit version"
},
"project.versions.open-in-new-tab-option": {
"message": "Open in new tab"
},
"project.versions.share-option": {
"message": "Share"
},
"project.versions.title": {
"message": "Versions"
},
"project.versions.version-deleted-text": {
"message": "The version has been successfully deleted."
},
"project.versions.version-deleted-title": {
"message": "Version deleted"
},
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}."
},
@@ -4121,8 +4517,11 @@
"servers.plan.small.description": {
"message": "Perfect for 15 friends with a few light mods."
},
"servers.purchase.step.plan.billed": {
"message": "billed {interval, select, monthly {monthly} quarterly {quarterly} yearly {yearly} other {{interval}}}"
"servers.purchase.step.plan.most-popular": {
"message": "Most popular"
},
"servers.purchase.step.plan.per-month-billing": {
"message": "/ month{interval, select, monthly {} other {, billed {interval, select, quarterly {quarterly} yearly {yearly} other {{interval}}}}}"
},
"settings.account.button.complete-setup": {
"message": "Complete setup"
@@ -4967,6 +5366,18 @@
"version.environment.unknown.title": {
"message": "Unknown environment"
},
"version.notification.deleted-text": {
"message": "The version has been successfully deleted."
},
"version.notification.deleted-title": {
"message": "Version deleted"
},
"version.notification.packaging-success-text": {
"message": "Your data pack was successfully packaged as a mod! Make sure to playtest to check for errors."
},
"version.notification.packaging-success-title": {
"message": "Packaging Success"
},
"version.package-as-mod.button": {
"message": "Package as mod"
},
@@ -4067,9 +4067,6 @@
"servers.plan.small.description": {
"message": "Perfecto para 15 amigos con algunos mods ligeros."
},
"servers.purchase.step.plan.billed": {
"message": "facturado {interval, select, monthly {mensual} quarterly {trimestral} yearly {anual} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Finalizar configuración"
},
@@ -3788,9 +3788,6 @@
"servers.plan.small.description": {
"message": "Perfecto para 15 amigos con algunas modificaciones ligeras."
},
"servers.purchase.step.plan.billed": {
"message": "facturado {interval, select, monthly {mensual} quarterly {trimestral} yearly {anual} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Configuración completa"
},
@@ -4073,9 +4073,6 @@
"servers.plan.small.description": {
"message": "Parfait pour 15 amis et quelques mods légers."
},
"servers.purchase.step.plan.billed": {
"message": "facturé {interval, select, monthly {mensuellement} quarterly {trimestriellement} yearly {annuellement} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Terminer la configuration"
},
@@ -4040,9 +4040,6 @@
"servers.plan.small.description": {
"message": "Perfetto per 15 giocatori e qualche mod leggera."
},
"servers.purchase.step.plan.billed": {
"message": "addebitato {interval, select, monthly {mensilmente} quarterly {trimerstralmente} yearly {annualmente} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Completa la configurazione"
},
@@ -4067,9 +4067,6 @@
"servers.plan.small.description": {
"message": "1–5명의 친구와 적은 수의 가벼운 모드에 적합합니다."
},
"servers.purchase.step.plan.billed": {
"message": "{interval, select, monthly {월간 결제} quarterly {분기별 결제} yearly {연간 결제} other {{interval} 결제}}"
},
"settings.account.button.complete-setup": {
"message": "설정 완료"
},
@@ -3572,9 +3572,6 @@
"servers.plan.small.description": {
"message": "Sesuai untuk 15 rakan dengan beberapa mod ringan."
},
"servers.purchase.step.plan.billed": {
"message": "dibilkan {interval, select, monthly {monthly} quarterly {quarterly} yearly {yearly} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Lengkapkan persediaan"
},
@@ -4058,9 +4058,6 @@
"servers.plan.small.description": {
"message": "Idealny dla 15 przyjaciół z kilkoma lekkimi modami."
},
"servers.purchase.step.plan.billed": {
"message": "naliczane {interval, select, monthly {miesięcznie} quarterly {co kwartał} yearly {rocznie} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Zakończ ustawianie"
},
@@ -4073,9 +4073,6 @@
"servers.plan.small.description": {
"message": "Perfeito para 15 amigos e alguns mods leves."
},
"servers.purchase.step.plan.billed": {
"message": "cobrado {interval, select, monthly {mensalmente} quarterly {trimestralmente} yearly {anualmente} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Terminar configuração"
},
@@ -4046,9 +4046,6 @@
"servers.plan.small.description": {
"message": "Для 1–5 друзей и пары лёгких модов."
},
"servers.purchase.step.plan.billed": {
"message": "{interval, select, monthly {на месяц} quarterly {на 3 месяца} yearly {на год} other {оплата {interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Завершить настройку"
},
@@ -1730,9 +1730,6 @@
"servers.plan.small.description": {
"message": "Savršeno za 15 prijatelja sa nekoliko lakih modova."
},
"servers.purchase.step.plan.billed": {
"message": "naplaćuje se {interval, select, monthly {mesečno} quarterly {kvartalno} yearly {godišnje} other {{interval}}}"
},
"settings.account.delete.section.description": {
"message": "Kad obrišeš svoj račun, povratka nema. Brisanjem računa sa naših servera biće uklonjeni svi povezani podaci, izuzev projekata."
},
@@ -3425,9 +3425,6 @@
"servers.plan.small.description": {
"message": "Perfekt för 15 vänner med några få lätta moddar."
},
"servers.purchase.step.plan.billed": {
"message": "fakturerad {interval, select, monthly {månadsvis} quarterly {kvartalvis} yearly {årligen} other {{interval}}}"
},
"settings.account.delete.confirm.proceed": {
"message": "Radera kontot"
},
@@ -4013,9 +4013,6 @@
"servers.plan.small.description": {
"message": "15 arkadaş ve birkaç hafif mod için mükemmel."
},
"servers.purchase.step.plan.billed": {
"message": "{interval, select, monthly {monthly} quarterly {quarterly} yearly {yearly} other {{interval}}} faturalandırıldı"
},
"settings.account.button.complete-setup": {
"message": "Kurulumu tamamla"
},
@@ -4064,9 +4064,6 @@
"servers.plan.small.description": {
"message": "Прекрасно підходить для 1–5 друзів із кількома легкими модами."
},
"servers.purchase.step.plan.billed": {
"message": "оплата {interval, select, monthly {щомісяця} quarterly {щокварталу} yearly {щороку} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Завершити налаштування"
},
@@ -3695,9 +3695,6 @@
"servers.plan.small.description": {
"message": "Hoàn hảo cho 15 người bạn với một vài mod nhẹ."
},
"servers.purchase.step.plan.billed": {
"message": "trả tiền hàng {interval, select, monthly {tháng} quarterly {quý (3 tháng)} yearly {năm} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "Hoàn tất thiết lập"
},
@@ -4067,9 +4067,6 @@
"servers.plan.small.description": {
"message": "适合 1–5 位朋友使用,加少量轻量模组。"
},
"servers.purchase.step.plan.billed": {
"message": "计费{interval, select, monthly {按月} quarterly {按季度} yearly {按年} other {{interval}}}"
},
"settings.account.button.complete-setup": {
"message": "完成设置"
},
@@ -4073,9 +4073,6 @@
"servers.plan.small.description": {
"message": "適合 1 到 5 位好友及少量輕量模組。"
},
"servers.purchase.step.plan.billed": {
"message": "按{interval, select, monthly {月} quarterly {季} yearly {年} other {{interval}}}計費"
},
"settings.account.button.complete-setup": {
"message": "完成設定"
},
+109 -6
View File
@@ -97,6 +97,22 @@
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
<ModpackScanModal ref="scanModal" :project_id="project.id" />
<div
v-if="projectInstallContext && !isSettings"
ref="stickyInstallHeaderRef"
class="sticky top-0 z-20 mx-auto max-w-[80rem] border-0 border-solid border-divider bg-surface-1 px-6 pt-4"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader
:install-context="projectHeaderInstallContext"
divider
bottom-padding
/>
</div>
<SelectedProjectsFloatingBar
v-if="projectInstallContext && !isSettings"
:install-context="projectInstallContext"
/>
<div
class="new-page sidebar"
:class="{
@@ -111,7 +127,10 @@
!flags.alwaysShowChecklistAsPopup,
}"
>
<div class="normal-page__header relative my-4">
<div
class="normal-page__header relative mb-4"
:class="projectInstallContext && !isSettings ? 'mt-0' : 'mt-4'"
>
<div class="mb-6">
<ModerationProjectNags
v-if="
@@ -616,6 +635,7 @@ import {
import {
Admonition,
Avatar,
BrowseInstallHeader,
ButtonStyled,
commonMessages,
defineMessages,
@@ -636,11 +656,13 @@ import {
ProjectSidebarServerInfo,
ProjectSidebarTags,
provideProjectPageContext,
SelectedProjectsFloatingBar,
TeleportOverflowMenu,
useDebugLogger,
useFormatDateTime,
useFormatPrice,
useRelativeTime,
useStickyObserver,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, formatProjectType, renderString } from '@modrinth/utils'
@@ -663,6 +685,7 @@ import { getSignInRouteObj } from '~/composables/auth.ts'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } 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'
import { injectCurrentProjectId } from '~/providers/current-project.ts'
import { loadChecklistState } from '~/services/moderation-checklist-storage.ts'
@@ -725,6 +748,11 @@ const scanModal = ref()
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const stickyInstallHeaderRef = ref(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'ProjectInstallHeader',
)
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
@@ -780,6 +808,10 @@ const messages = defineMessages({
defaultMessage:
'{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project.',
},
backToDiscover: {
id: 'project.install-context.back-to-discover',
defaultMessage: 'Back to discover',
},
changelogTab: {
id: 'project.navigation.changelog',
defaultMessage: 'Changelog',
@@ -1029,6 +1061,51 @@ const project = computed(() => {
}
})
const routeProjectType = computed(() =>
Array.isArray(route.params.type) ? route.params.type[0] : route.params.type,
)
const projectInstallType = computed(() => ({
id: project.value?.actualProjectType ?? routeProjectType.value,
}))
const serverInstallModalRef = ref(null)
const serverInstallDebug = useDebugLogger('ProjectServerInstall')
const { installContext: serverBrowseInstallContext } = useServerInstallContent({
projectType: projectInstallType,
onboardingModalRef: serverInstallModalRef,
debug: serverInstallDebug,
})
const projectDiscoverBackUrl = computed(() => {
const discoverType =
routeProjectType.value === 'project'
? (project.value?.actualProjectType ?? project.value?.project_type ?? 'mod')
: (routeProjectType.value ?? project.value?.actualProjectType ?? 'mod')
return `/discover/${discoverType}s${getInstallContextQueryString(['sid', 'wid', 'from', 'shi'])}`
})
const projectInstallContext = computed(() => {
const context = serverBrowseInstallContext.value
if (!context) return null
return {
...context,
backUrl: projectDiscoverBackUrl.value,
backLabel: formatMessage(messages.backToDiscover),
discardSelectedAndBack: async () => {
await (context.clearSelected ?? context.clearQueued)?.()
await navigateTo(projectDiscoverBackUrl.value)
},
}
})
const projectHeaderInstallContext = computed(() => {
const context = projectInstallContext.value
if (!context) return null
return {
...context,
onBack: undefined,
selectedProjects: [],
isInstallingSelected: false,
}
})
// Use actual project ID for dependent queries (ensures cache consistency)
const projectId = computed(() => projectRaw.value?.id)
@@ -2067,6 +2144,32 @@ function triggerDownloadAnimation() {
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
const INSTALL_CONTEXT_QUERY_KEYS = ['sid', 'wid', 'from', 'shi']
function getInstallContextQueryString(keys = INSTALL_CONTEXT_QUERY_KEYS) {
const params = new URLSearchParams()
for (const key of keys) {
const value = route.query[key]
if (Array.isArray(value)) {
for (const item of value) {
if (item != null) {
params.append(key, item)
}
}
} else if (value != null) {
params.append(key, value)
}
}
const queryString = params.toString()
return queryString ? `?${queryString}` : ''
}
function withInstallContextQuery(path) {
return `${path}${getInstallContextQueryString()}`
}
async function deleteVersion(id) {
if (!id) return
@@ -2091,16 +2194,16 @@ const navLinks = computed(() => {
return [
{
label: formatMessage(messages.descriptionTab),
href: projectUrl,
href: withInstallContextQuery(projectUrl),
},
{
label: formatMessage(messages.galleryTab),
href: `${projectUrl}/gallery`,
href: withInstallContextQuery(`${projectUrl}/gallery`),
shown: galleryCount > 0 || !!currentMember.value,
},
{
label: formatMessage(messages.changelogTab),
href: `${projectUrl}/changelog`,
href: withInstallContextQuery(`${projectUrl}/changelog`),
shown:
hasVersions.value &&
projectV3Loaded.value &&
@@ -2109,7 +2212,7 @@ const navLinks = computed(() => {
},
{
label: formatMessage(messages.versionsTab),
href: `${projectUrl}/versions`,
href: withInstallContextQuery(`${projectUrl}/versions`),
shown:
(hasVersions.value || !!currentMember.value) &&
projectV3Loaded.value &&
@@ -2119,7 +2222,7 @@ const navLinks = computed(() => {
},
{
label: formatMessage(messages.moderationTab),
href: `${projectUrl}/moderation`,
href: withInstallContextQuery(`${projectUrl}/moderation`),
shown: !!currentMember.value,
},
]
@@ -3,19 +3,21 @@
<ConfirmLeaveModal ref="confirmLeaveModal" />
<section class="universal-card">
<div class="flex flex-col gap-6">
<div class="text-2xl font-semibold text-contrast">Server details</div>
<div class="text-2xl font-semibold text-contrast">
{{ formatMessage(messages.serverDetailsHeading) }}
</div>
<!-- Region -->
<div class="max-w-[600px]">
<label for="server-region">
<span class="label__title">Region</span>
<span class="label__title">{{ formatMessage(messages.regionLabel) }}</span>
</label>
<Combobox
id="server-region"
v-model="region"
:options="regionOptions"
searchable
placeholder="Select region"
:placeholder="formatMessage(messages.selectRegionPlaceholder)"
:disabled="!hasPermission"
/>
</div>
@@ -24,7 +26,10 @@
<div class="max-w-[600px]">
<label for="server-language">
<span class="label__title"
>Languages <span class="font-normal text-secondary">(optional)</span></span
>{{ formatMessage(messages.languagesLabel) }}
<span class="font-normal text-secondary"
>({{ formatMessage(messages.optionalLabel) }})</span
></span
>
</label>
<MultiSelect
@@ -34,7 +39,7 @@
searchable
include-select-all-option
:max-tag-rows="2"
placeholder="Select languages"
:placeholder="formatMessage(messages.selectLanguagesPlaceholder)"
:disabled="!hasPermission"
/>
</div>
@@ -43,7 +48,9 @@
<div class="max-w-[600px]">
<div class="flex items-center justify-between">
<label for="java-address">
<span class="label__title !m-0 !text-contrast">Java address</span>
<span class="label__title !m-0 !text-contrast">{{
formatMessage(messages.javaAddressLabel)
}}</span>
</label>
</div>
<div
@@ -58,7 +65,7 @@
<StyledInput
id="java-address"
v-model="javaAddress"
placeholder="Enter address"
:placeholder="formatMessage(messages.enterAddressPlaceholder)"
:disabled="!hasPermission"
wrapper-class="flex-grow"
autocomplete="off"
@@ -80,7 +87,7 @@
color="oranges"
>
<button
v-tooltip="'Refresh ping'"
v-tooltip="formatMessage(messages.refreshPingTooltip)"
:disabled="javaPingLoading"
@click="pingJavaServer"
>
@@ -92,34 +99,35 @@
v-if="javaPingResult !== null && !javaPingLoading && javaPingResult.online"
class="mt-0.5 flex items-center gap-1.5 text-green"
>
Server is online!
{{ formatMessage(messages.serverOnline) }}
<template v-if="javaPingResult.latency">
Latency: {{ javaPingResult.latency }}ms
{{ formatMessage(messages.latencyLabel, { latency: javaPingResult.latency }) }}
</template>
</div>
<div v-else-if="javaPingResult !== null && !javaPingLoading" class="mt-0.5 text-orange">
We couldnt ping this server. It may be blocked by your host so try refreshing a few
times. If it still doesnt respond please
<a
class="inline underline"
href="https://support.modrinth.com"
target="_blank"
rel="noopener noreferrer"
>
contact support</a
>.
<IntlFormatted :message-id="messages.pingFailedMessage">
<template #support-link="{ children }">
<a
class="inline underline"
href="https://support.modrinth.com"
target="_blank"
rel="noopener noreferrer"
><component :is="() => normalizeChildren(children)"
/></a>
</template>
</IntlFormatted>
</div>
</div>
<div v-else class="mt-2 text-sm">
If you have [SRV records]
<InfoIcon
v-tooltip="{
content:
'The address you enter here may have DNS SRV records _minecraft._tcp.{your domain} which point to your Minecraft server address and port.',
popperClass: 'max-w-xs',
}"
/>, you do not need to add a port. Otherwise if you have a port which isn't 25565, you
can include it as :12345
<IntlFormatted :message-id="messages.srvRecordsHint">
<template #srv-tooltip="{ children }"
><component :is="() => normalizeChildren(children)" /><InfoIcon
v-tooltip="{
content: formatMessage(messages.srvRecordsTooltip),
popperClass: 'max-w-xs',
}"
/></template>
</IntlFormatted>
</div>
</div>
@@ -127,15 +135,17 @@
<div class="max-w-[600px]">
<label for="bedrock-address">
<span class="label__title !text-contrast"
>Bedrock address
<span class="font-normal text-secondary">(optional)</span>
>{{ formatMessage(messages.bedrockAddressLabel) }}
<span class="font-normal text-secondary"
>({{ formatMessage(messages.optionalLabel) }})</span
>
</span>
</label>
<div class="mt-2 flex items-center gap-2">
<StyledInput
id="bedrock-address"
v-model="bedrockAddress"
placeholder="Enter address"
:placeholder="formatMessage(messages.enterAddressPlaceholder)"
:disabled="!hasPermission"
wrapper-class="flex-grow"
autocomplete="off"
@@ -163,10 +173,13 @@ import {
ButtonStyled,
Combobox,
ConfirmLeaveModal,
defineMessages,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
IntlFormatted,
MultiSelect,
normalizeChildren,
SERVER_LANGUAGES,
SERVER_REGIONS,
StyledInput,
@@ -181,6 +194,81 @@ const PING_TIMEOUT_MS = 5000
const { formatMessage, locale } = useVIntl()
const messages = defineMessages({
serverDetailsHeading: {
id: 'project.settings.server.details-heading',
defaultMessage: 'Server details',
},
regionLabel: {
id: 'project.settings.server.region-label',
defaultMessage: 'Region',
},
selectRegionPlaceholder: {
id: 'project.settings.server.select-region-placeholder',
defaultMessage: 'Select region',
},
languagesLabel: {
id: 'project.settings.server.languages-label',
defaultMessage: 'Languages',
},
optionalLabel: {
id: 'project.settings.server.optional-label',
defaultMessage: 'optional',
},
selectLanguagesPlaceholder: {
id: 'project.settings.server.select-languages-placeholder',
defaultMessage: 'Select languages',
},
javaAddressLabel: {
id: 'project.settings.server.java-address-label',
defaultMessage: 'Java address',
},
enterAddressPlaceholder: {
id: 'project.settings.server.enter-address-placeholder',
defaultMessage: 'Enter address',
},
refreshPingTooltip: {
id: 'project.settings.server.refresh-ping-tooltip',
defaultMessage: 'Refresh ping',
},
serverOnline: {
id: 'project.settings.server.server-online',
defaultMessage: 'Server is online!',
},
latencyLabel: {
id: 'project.settings.server.latency-label',
defaultMessage: 'Latency: {latency}ms',
},
pingFailedMessage: {
id: 'project.settings.server.ping-failed-message',
defaultMessage:
"We couldn't ping this server. It may be blocked by your host so try refreshing a few times. If it still doesn't respond please <support-link>contact support</support-link>.",
},
srvRecordsHint: {
id: 'project.settings.server.srv-records-hint',
defaultMessage:
"If you have <srv-tooltip>[SRV records]</srv-tooltip>, you do not need to add a port. Otherwise if you have a port which isn't 25565, you can include it as :12345",
},
srvRecordsTooltip: {
id: 'project.settings.server.srv-records-tooltip',
defaultMessage:
'The address you enter here may have DNS SRV records _minecraft._tcp.(your domain) which point to your Minecraft server address and port.',
},
bedrockAddressLabel: {
id: 'project.settings.server.bedrock-address-label',
defaultMessage: 'Bedrock address',
},
cannotSaveTitle: {
id: 'project.settings.server.cannot-save-title',
defaultMessage: 'Cannot save',
},
cannotSaveText: {
id: 'project.settings.server.cannot-save-text',
defaultMessage:
'The Java server must be reachable before saving. Please ensure the ping succeeds.',
},
})
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
@@ -391,8 +479,8 @@ function resetChanges() {
async function handleSave() {
if (javaAddress.value.trim() && !javaPingResult.value?.online) {
addNotification({
title: 'Cannot save',
text: 'The Java server must be reachable before saving. Please ensure the ping succeeds.',
title: formatMessage(messages.cannotSaveTitle),
text: formatMessage(messages.cannotSaveText),
type: 'error',
})
return
@@ -4,7 +4,7 @@
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Tags</span>
<span class="label__title size-card-header">{{ formatMessage(messages.tagsTitle) }}</span>
</h3>
</div>
@@ -27,16 +27,18 @@
</div>
<p>
Accurate tagging is important to help people find your
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
{{
formatMessage(messages.taggingImportanceDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</p>
<p
v-if="project.versions.length === 0 && projectV3?.minecraft_server == null"
class="known-errors"
>
Please upload a version first in order to select tags!
{{ formatMessage(messages.uploadVersionFirst) }}
</p>
<template v-else>
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
@@ -46,22 +48,32 @@
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
Select all categories that reflect the themes or function of your
{{ formatProjectType(project.project_type).toLowerCase() }}.
{{
formatMessage(messages.categoriesDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</template>
<template v-else-if="header === 'features'">
Select all of the features that your
{{ formatProjectType(project.project_type).toLowerCase() }} makes use of.
{{
formatMessage(messages.featuresDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</template>
<template v-else-if="header === 'resolutions'">
Select the resolution(s) of textures in your
{{ formatProjectType(project.project_type).toLowerCase() }}.
{{
formatMessage(messages.resolutionsDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</template>
<template v-else-if="header === 'performance impact'">
Select the realistic performance impact of your
{{ formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
{{ formatProjectType(project.project_type).toLowerCase() }} is configurable to
different levels of performance impact.
{{
formatMessage(messages.performanceImpactDescription, {
type: formatProjectType(project.project_type).toLowerCase(),
})
}}
</template>
</span>
</div>
@@ -90,15 +102,16 @@
</template>
<div class="label">
<h4>
<span class="label__title"><StarIcon /> Featured tags</span>
<span class="label__title"
><StarIcon /> {{ formatMessage(messages.featuredTags) }}</span
>
</h4>
<span class="label__description">
You can feature up to 3 of your most relevant tags. Other tags may be promoted to
featured if you do not select all 3.
</span>
<span class="label__description">{{
formatMessage(messages.featuredTagsDescription)
}}</span>
</div>
<p v-if="current.selectedTags.length < 1">
Select at least one category in order to feature a category.
{{ formatMessage(messages.selectAtLeastOneCategory) }}
</p>
<div class="category-list input-div">
<Checkbox
@@ -145,6 +158,7 @@ import {
import {
Checkbox,
ConfirmLeaveModal,
defineMessages,
formatCategory,
formatCategoryHeader,
FormattedTag,
@@ -167,6 +181,77 @@ interface Category {
const tags = useGeneratedState()
const { formatMessage, locale } = useVIntl()
const messages = defineMessages({
tagsTitle: {
id: 'project.settings.tags.title',
defaultMessage: 'Tags',
},
taggingImportanceDescription: {
id: 'project.settings.tags.tagging-importance-description',
defaultMessage:
'Accurate tagging is important to help people find your {type}. Make sure to select all tags that apply.',
},
uploadVersionFirst: {
id: 'project.settings.tags.upload-version-first',
defaultMessage: 'Please upload a version first in order to select tags!',
},
categoriesDescription: {
id: 'project.settings.tags.categories-description',
defaultMessage: 'Select all categories that reflect the themes or function of your {type}.',
},
featuresDescription: {
id: 'project.settings.tags.features-description',
defaultMessage: 'Select all of the features that your {type} makes use of.',
},
resolutionsDescription: {
id: 'project.settings.tags.resolutions-description',
defaultMessage: 'Select the resolution(s) of textures in your {type}.',
},
performanceImpactDescription: {
id: 'project.settings.tags.performance-impact-description',
defaultMessage:
'Select the realistic performance impact of your {type}. Select multiple if the {type} is configurable to different levels of performance impact.',
},
featuredTags: {
id: 'project.settings.tags.featured-tags',
defaultMessage: 'Featured tags',
},
featuredTagsDescription: {
id: 'project.settings.tags.featured-tags-description',
defaultMessage:
'You can feature up to 3 of your most relevant tags. Other tags may be promoted to featured if you do not select all 3.',
},
selectAtLeastOneCategory: {
id: 'project.settings.tags.select-at-least-one-category',
defaultMessage: 'Select at least one category in order to feature a category.',
},
tooManyTagsServerHardWarning: {
id: 'project.settings.tags.too-many-tags-server-hard-warning',
defaultMessage:
"You've selected {count} tags. Please reduce to 18 or fewer to keep your server focused and easier to discover.",
},
tooManyTagsServerSoftWarning: {
id: 'project.settings.tags.too-many-tags-server-soft-warning',
defaultMessage:
"You've selected {count} tags. Consider reducing to 12 or fewer to keep your server focused and easier to discover.",
},
tooManyTagsProjectWarning: {
id: 'project.settings.tags.too-many-tags-project-warning',
defaultMessage:
"You've selected {count} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.",
},
multipleResolutionTagsWarning: {
id: 'project.settings.tags.multiple-resolution-tags-warning',
defaultMessage:
"You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag.",
},
allTagsSelectedWarning: {
id: 'project.settings.tags.all-tags-selected-warning',
defaultMessage:
"You've selected all {count} available tags. Please select only the tags that truly apply to your project.",
},
})
const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
const formatCategoryName = (categoryName: string) => {
@@ -268,12 +353,12 @@ const tooManyTagsWarning = computed(() => {
const tagCount = current.value.selectedTags.length
if (projectV3?.value?.minecraft_server != null) {
if (tagCount > 18) {
return `You've selected ${tagCount} tags. Please reduce to 18 or fewer to keep your server focused and easier to discover.`
return formatMessage(messages.tooManyTagsServerHardWarning, { count: tagCount })
} else if (tagCount > 12) {
return `You've selected ${tagCount} tags. Consider reducing to 12 or fewer to keep your server focused and easier to discover.`
return formatMessage(messages.tooManyTagsServerSoftWarning, { count: tagCount })
}
} else if (tagCount > 8) {
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`
return formatMessage(messages.tooManyTagsProjectWarning, { count: tagCount })
}
return null
})
@@ -286,14 +371,15 @@ const multipleResolutionTagsWarning = computed(() => {
)
if (resolutionTags.length > 1) {
return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags
const tagsList = resolutionTags
.map((t) => t.name)
.join(', ')
.replace('8x-', '8x or lower')
.replace(
'512x+',
'512x or higher',
)}). Resource packs should typically only have one resolution tag.`
.replace('512x+', '512x or higher')
return formatMessage(messages.multipleResolutionTagsWarning, {
count: resolutionTags.length,
tags: tagsList,
})
}
return null
})
@@ -310,7 +396,9 @@ const allTagsSelectedWarning = computed(() => {
totalSelectedTags === categoriesForProjectType.length &&
categoriesForProjectType.length > 0
) {
return `You've selected all ${categoriesForProjectType.length} available tags. Please select only the tags that truly apply to your project.`
return formatMessage(messages.allTagsSelectedWarning, {
count: categoriesForProjectType.length,
})
}
return null
})
@@ -8,10 +8,10 @@
<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:title="formatMessage(messages.deleteVersionConfirmTitle)"
:description="formatMessage(messages.deleteVersionConfirmDescription)"
:has-to-type="false"
proceed-label="Delete"
:proceed-label="formatMessage(messages.deleteButton)"
@proceed="deleteVersion()"
/>
<Admonition
@@ -69,7 +69,7 @@
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<OverflowMenu
v-tooltip="'Edit version'"
v-tooltip="formatMessage(messages.editVersionTooltip)"
class="hover:!bg-button-bg [&>svg]:!text-green"
:dropdown-id="`${baseDropdownId}-edit-${version.id}`"
:options="[
@@ -86,26 +86,26 @@
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
},
]"
aria-label="Edit version"
:aria-label="formatMessage(messages.editVersionTooltip)"
>
<EditIcon aria-hidden="true" />
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
{{ formatMessage(messages.editFilesOption) }}
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
{{ formatMessage(messages.editDetailsOption) }}
</template>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit metadata
{{ formatMessage(messages.editMetadataOption) }}
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
v-tooltip="'More options'"
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-${version.id}`"
:options="[
@@ -192,52 +192,52 @@
shown: !!currentMember,
},
]"
aria-label="More options"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon aria-hidden="true" />
<template #download>
<DownloadIcon aria-hidden="true" />
Download
{{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #new-tab>
<ExternalIcon aria-hidden="true" />
Open in new tab
{{ formatMessage(messages.openInNewTabOption) }}
</template>
<template #copy-link>
<LinkIcon aria-hidden="true" />
Copy link
{{ formatMessage(messages.copyLinkOption) }}
</template>
<template #share>
<ShareIcon aria-hidden="true" />
Share
{{ formatMessage(messages.shareOption) }}
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
{{ formatMessage(messages.editFilesOption) }}
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
{{ formatMessage(messages.editDetailsOption) }}
</template>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit metadata
{{ formatMessage(messages.editMetadataOption) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
Delete
{{ formatMessage(commonMessages.deleteLabel) }}
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
{{ formatMessage(messages.copyIdOption) }}
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Maven coordinates
{{ formatMessage(messages.copyMavenCoordinatesOption) }}
</template>
</OverflowMenu>
</ButtonStyled>
@@ -336,6 +336,7 @@ import {
import {
Admonition,
ButtonStyled,
commonMessages,
commonProjectSettingsMessages,
ConfirmModal,
defineMessages,
@@ -444,13 +445,13 @@ async function deleteVersion() {
await client.labrinth.versions_v3.deleteVersion(id)
addNotification({
title: 'Version deleted',
text: 'The version has been successfully deleted.',
title: formatMessage(messages.versionDeletedTitle),
text: formatMessage(messages.versionDeletedText),
type: 'success',
})
} catch (err: any) {
addNotification({
title: 'An error occurred',
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
@@ -477,5 +478,61 @@ const messages = defineMessages({
defaultMessage:
'{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}.',
},
deleteVersionConfirmTitle: {
id: 'project.versions.delete-confirm.title',
defaultMessage: 'Are you sure you want to delete this version?',
},
deleteVersionConfirmDescription: {
id: 'project.versions.delete-confirm.description',
defaultMessage: 'This will remove this version forever (like really forever).',
},
deleteButton: {
id: 'project.versions.delete-button',
defaultMessage: 'Delete',
},
editVersionTooltip: {
id: 'project.versions.edit-version-tooltip',
defaultMessage: 'Edit version',
},
editFilesOption: {
id: 'project.versions.edit-files-option',
defaultMessage: 'Edit files',
},
editDetailsOption: {
id: 'project.versions.edit-details-option',
defaultMessage: 'Edit details',
},
editMetadataOption: {
id: 'project.versions.edit-metadata-option',
defaultMessage: 'Edit metadata',
},
openInNewTabOption: {
id: 'project.versions.open-in-new-tab-option',
defaultMessage: 'Open in new tab',
},
copyLinkOption: {
id: 'project.versions.copy-link-option',
defaultMessage: 'Copy link',
},
shareOption: {
id: 'project.versions.share-option',
defaultMessage: 'Share',
},
copyIdOption: {
id: 'project.versions.copy-id-option',
defaultMessage: 'Copy ID',
},
copyMavenCoordinatesOption: {
id: 'project.versions.copy-maven-coordinates-option',
defaultMessage: 'Copy Maven coordinates',
},
versionDeletedTitle: {
id: 'project.versions.version-deleted-title',
defaultMessage: 'Version deleted',
},
versionDeletedText: {
id: 'project.versions.version-deleted-text',
defaultMessage: 'The version has been successfully deleted.',
},
})
</script>
@@ -388,7 +388,7 @@
projectV3.project_types.includes('mod') ||
projectV3.project_types.includes('plugin')
"
class="flex flex-col overflow-hidden rounded-2xl border-[1px] border-solid border-surface-4 bg-surface-2 p-0"
class="mb-4 flex flex-col overflow-hidden rounded-2xl border-[1px] border-solid border-surface-4 bg-surface-2 p-0"
>
<button
class="group m-0 flex w-full min-w-0 appearance-none items-center gap-3 rounded-2xl rounded-b-none bg-surface-3 p-4 text-left outline-offset-[-3px]"
@@ -832,8 +832,8 @@ const deleteVersionMutation = useMutation({
mutationFn: () => client.labrinth.versions_v3.deleteVersion(version.value!.id),
onSuccess: async () => {
addNotification({
title: 'Version deleted',
text: 'The version has been successfully deleted.',
title: formatMessage(messages.versionDeletedTitle),
text: formatMessage(messages.versionDeletedText),
type: 'success',
})
await invalidate()
@@ -841,7 +841,7 @@ const deleteVersionMutation = useMutation({
},
onError: (err: { data?: { description?: string } }) => {
addNotification({
title: 'An error occurred',
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data?.description ?? String(err),
type: 'error',
})
@@ -894,8 +894,8 @@ const createDataPackVersionMutation = useMutation({
packageModal.value?.hide()
addNotification({
title: 'Packaging Success',
text: 'Your data pack was successfully packaged as a mod! Make sure to playtest to check for errors.',
title: formatMessage(messages.packagingSuccessTitle),
text: formatMessage(messages.packagingSuccessText),
type: 'success',
})
@@ -906,7 +906,7 @@ const createDataPackVersionMutation = useMutation({
},
onError: (err: { data?: { description?: string } }) => {
addNotification({
title: 'An error occurred',
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data?.description ?? String(err),
type: 'error',
})
@@ -939,6 +939,23 @@ const messages = defineMessages({
id: 'version.download.no-primary-file',
defaultMessage: 'Error: No primary file found',
},
versionDeletedTitle: {
id: 'version.notification.deleted-title',
defaultMessage: 'Version deleted',
},
versionDeletedText: {
id: 'version.notification.deleted-text',
defaultMessage: 'The version has been successfully deleted.',
},
packagingSuccessTitle: {
id: 'version.notification.packaging-success-title',
defaultMessage: 'Packaging Success',
},
packagingSuccessText: {
id: 'version.notification.packaging-success-text',
defaultMessage:
'Your data pack was successfully packaged as a mod! Make sure to playtest to check for errors.',
},
downloadVersion: {
id: 'version.download.version',
defaultMessage: 'Download {version} ({size})',
@@ -72,7 +72,7 @@
:show-icon="false"
accept="image/png,image/jpeg,image/gif,image/webp"
class="hidden"
aria-label="Upload icon"
:aria-label="formatMessage(messages.uploadIconAriaLabel)"
@change="showPreviewImage"
/>
</div>
@@ -371,7 +371,7 @@
<ButtonStyled v-if="auth.user && auth.user.id === creator.id" color="brand">
<nuxt-link class="mx-auto w-min" to="/discover/mods">
<CompassIcon class="size-5" />
Discover mods
{{ formatMessage(messages.discoverModsButton) }}
</nuxt-link>
</ButtonStyled>
</template>
@@ -534,6 +534,14 @@ const messages = defineMessages({
id: 'collection.label.no-projects',
defaultMessage: 'No projects in collection yet',
},
discoverModsButton: {
id: 'collection.button.discover-mods',
defaultMessage: 'Discover mods',
},
uploadIconAriaLabel: {
id: 'collection.aria-label.upload-icon',
defaultMessage: 'Upload icon',
},
projectsCountLabel: {
id: 'collection.label.projects-count',
defaultMessage: '{count, plural, =0 {No projects yet} other {<stat>{count}</stat> {type}}}',
+7 -10
View File
@@ -4,7 +4,6 @@ import { commonProjectTypeCategoryMessages, NavTabs, useVIntl } from '@modrinth/
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const route = useRoute()
const allowTabChanging = computed(() => !route.query.sid)
@@ -48,15 +47,13 @@ const selectableProjectTypes = [
]
</script>
<template>
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
<section class="normal-page__header mb-4 flex flex-col gap-4">
<NavTabs
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
:links="selectableProjectTypes"
replace
class="hidden md:flex"
/>
</section>
<div class="mx-auto box-border flex w-full max-w-[1280px] flex-col gap-4 px-6 pb-6">
<NavTabs
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
:links="selectableProjectTypes"
replace
class="hidden md:flex"
/>
<NuxtPage />
</div>
</template>
+111 -106
View File
@@ -33,6 +33,7 @@ import { cycleValue } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { useTimeoutFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import type { LocationQueryRaw } from 'vue-router'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
@@ -55,10 +56,6 @@ const client = injectModrinthClient()
const queryClient = useQueryClient()
const filtersMenuOpen = ref(false)
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
useStickyObserver(stickyInstallHeaderRef, 'DiscoverInstallHeader')
const route = useRoute()
const cosmetics = useCosmetics()
@@ -202,49 +199,83 @@ function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject
return undefined
}
async function search(requestParams: string) {
type DiscoverProjectSearchHit = Labrinth.Search.v2.ResultSearchProject & {
version_id?: string | null
}
function mapV3ProjectHit(hit: Labrinth.Search.v3.ResultSearchProject): DiscoverProjectSearchHit {
return {
...hit,
project_type: hit.project_types[0] ?? projectTypeId.value,
title: hit.name,
description: hit.summary,
versions: hit.version_id ? [hit.version_id] : [],
latest_version: hit.version_id,
icon_url: hit.icon_url ?? '',
client_side: 'unknown',
server_side: 'unknown',
}
}
const hostingContextQuery = computed(() => {
const query: LocationQueryRaw = {}
for (const key of ['sid', 'wid', 'from', 'shi']) {
const value = route.query[key]
if (value != null) {
query[key] = value
}
}
return Object.keys(query).length > 0 ? query : undefined
})
function withHostingContext(path: string) {
return hostingContextQuery.value ? { path, query: hostingContextQuery.value } : path
}
async function fetchSearch(requestParams: string) {
debug('search() called', {
requestParams: requestParams.substring(0, 100),
isServer: isServerType.value,
projectTypeId: projectTypeId.value,
})
const config = useRuntimeConfig()
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (isServerType.value) {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
}
const url = `${base}search${requestParams}`
debug('search() fetching:', url.substring(0, 120))
const raw = await $fetch<Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults>(
url,
{
headers: withLabrinthCanaryHeader(),
},
)
const raw = await client.request<Labrinth.Search.v3.SearchResults>('/search', {
api: 'labrinth',
version: 3,
method: 'GET',
params: Object.fromEntries(new URLSearchParams(requestParams.replace(/^\?/, ''))),
headers: withLabrinthCanaryHeader(),
})
debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length })
if ('hits_per_page' in raw) {
// v3 response (servers)
if (isServerType.value) {
return {
projectHits: [],
serverHits: raw.hits as Labrinth.Search.v3.ResultSearchProject[],
serverHits: raw.hits,
total_hits: raw.total_hits,
per_page: raw.hits_per_page,
}
}
return {
projectHits: raw.hits as Labrinth.Search.v2.ResultSearchProject[],
projectHits: raw.hits.map(mapV3ProjectHit),
serverHits: [],
total_hits: raw.total_hits,
per_page: raw.limit,
per_page: raw.hits_per_page,
}
}
async function search(requestParams: string) {
return await queryClient.ensureQueryData({
queryKey: ['discover', 'search', 'v3', requestParams],
queryFn: () => fetchSearch(requestParams),
staleTime: 30_000,
})
}
function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
@@ -424,7 +455,7 @@ watch(
)
debug('calling initial refreshSearch')
searchState.refreshSearch()
await searchState.refreshSearch()
const ogTitle = computed(() =>
searchState.query.value
@@ -462,9 +493,11 @@ provideBrowseManager({
projectType: projectTypeId,
...searchState,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) =>
`/${projectType.value?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`,
withHostingContext(
`/${projectType.value?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`,
),
getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) =>
`/server/${result.slug ?? result.project_id}`,
withHostingContext(`/server/${result.slug ?? result.project_id}`),
selectableProjectTypes: computed(() => []),
showProjectTypeTabs: computed(() => false),
variant: 'web',
@@ -504,25 +537,41 @@ provideBrowseManager({
},
loadingComponent: LogoAnimated,
})
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'DiscoverInstallHeader',
)
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<div
v-if="installContext"
ref="stickyInstallHeaderRef"
class="normal-page__header browse-install-header-bleed sticky top-0 z-20 mb-4 flex flex-col gap-2 border-0 bg-surface-1 py-3"
class="sticky top-0 z-20 -mx-6 border-0 border-solid border-divider bg-surface-1 px-6 pt-4"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader />
<BrowseInstallHeader divider bottom-padding />
</div>
<SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" />
<aside class="normal-page__sidebar" :aria-label="formatMessage(commonMessages.filtersLabel)">
<AdPlaceholder v-if="!auth.user && !serverData" />
<BrowseSidebar />
</aside>
<section class="normal-page__content">
<div class="flex flex-col gap-3">
<div
class="grid min-w-0 gap-3"
:class="
cosmetics.rightSearchLayout
? 'lg:grid-cols-[minmax(0,1fr)_18.75rem]'
: 'lg:grid-cols-[18.75rem_minmax(0,1fr)]'
"
>
<section
class="flex min-w-0 flex-col gap-3"
:class="cosmetics.rightSearchLayout ? 'lg:order-1' : 'lg:order-2'"
>
<BrowsePageLayout>
<template #display-mode-icon>
<GridIcon v-if="resultsDisplayMode === 'grid'" />
@@ -530,78 +579,34 @@ provideBrowseManager({
<ListIcon v-else />
</template>
</BrowsePageLayout>
<CreationFlowModal
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef"
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onOnboardingBack"
@hide="onOnboardingHide"
@browse-modpacks="() => {}"
@create="onModpackFlowCreate"
/>
</div>
</section>
</section>
<aside
class="min-w-0"
:class="cosmetics.rightSearchLayout ? 'lg:order-2' : 'lg:order-1'"
:aria-label="formatMessage(commonMessages.filtersLabel)"
>
<BrowseSidebar>
<template #prepend>
<AdPlaceholder v-if="!auth.user && !serverData" />
</template>
</BrowseSidebar>
</aside>
</div>
<CreationFlowModal
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef"
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onOnboardingBack"
@hide="onOnboardingHide"
@browse-modpacks="() => {}"
@create="onModpackFlowCreate"
/>
</template>
<style lang="scss" scoped>
.browse-install-header-bleed {
grid-column: 1 / -1;
margin-inline: -1.5rem;
padding-inline: 0.75rem !important;
&::after {
content: '';
position: absolute;
right: 50%;
bottom: 0;
width: 100vw;
border-bottom: 1px solid var(--surface-5);
transform: translateX(50%);
}
}
.normal-page__content {
display: contents;
@media screen and (min-width: 1024px) {
display: block;
}
}
.normal-page__sidebar {
grid-row: 3;
@media screen and (min-width: 1024px) {
display: block;
}
}
.filters-card {
padding: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
padding: var(--spacing-card-lg);
}
}
.content-wrapper {
grid-row: 1;
}
.pagination-after {
grid-row: 6;
}
.no-results {
text-align: center;
display: flow-root;
}
.loading-logo {
margin: 2rem;
}
.search-background {
width: 100%;
height: 20rem;
+32 -7
View File
@@ -680,6 +680,31 @@ const formatPrice = useFormatPrice()
const flags = useFeatureFlags()
const messages = defineMessages({
errorFetchingPaymentDataTitle: {
id: 'hosting-marketing.notification.error-fetching-payment-data-title',
defaultMessage: 'Error fetching payment data',
},
unexpectedErrorText: {
id: 'hosting-marketing.notification.unexpected-error-text',
defaultMessage: 'An unexpected error occurred',
},
serverCapacityFullTitle: {
id: 'hosting-marketing.notification.server-capacity-full-title',
defaultMessage: 'Server Capacity Full',
},
serverCapacityFullText: {
id: 'hosting-marketing.notification.server-capacity-full-text',
defaultMessage: 'We are currently at capacity. Please try again later.',
},
invalidProductTitle: {
id: 'hosting-marketing.notification.invalid-product-title',
defaultMessage: 'Invalid product',
},
invalidProductText: {
id: 'hosting-marketing.notification.invalid-product-text',
defaultMessage:
'The selected product was found but lacks necessary data. Please contact support.',
},
hostWithModrinth: {
id: 'hosting-marketing.hero.host-with-modrinth',
defaultMessage: 'Host your next server with Modrinth Hosting',
@@ -1106,7 +1131,7 @@ const startTyping = () => {
const handleError = (err) => {
addNotification({
title: 'An error occurred',
title: formatMessage(commonMessages.errorNotificationTitle),
type: 'error',
text: err.message ?? (err.data ? err.data.description : err),
})
@@ -1124,9 +1149,9 @@ async function fetchPaymentData() {
} catch (error) {
console.error('Error fetching payment data:', error)
addNotification({
title: 'Error fetching payment data',
title: formatMessage(messages.errorFetchingPaymentDataTitle),
type: 'error',
text: error.message || 'An unexpected error occurred',
text: error.message || formatMessage(messages.unexpectedErrorText),
})
}
}
@@ -1177,9 +1202,9 @@ const selectProduct = async (product) => {
if ((product === 'custom' && isCustomAtCapacity.value) || isAtCapacity.value) {
addNotification({
title: 'Server Capacity Full',
title: formatMessage(messages.serverCapacityFullTitle),
type: 'error',
text: 'We are currently at capacity. Please try again later.',
text: formatMessage(messages.serverCapacityFullText),
})
return
}
@@ -1192,9 +1217,9 @@ const selectProduct = async (product) => {
(product !== 'custom' && !selectedPlan.metadata)
) {
addNotification({
title: 'Invalid product',
title: formatMessage(messages.invalidProductTitle),
type: 'error',
text: 'The selected product was found but lacks necessary data. Please contact support.',
text: formatMessage(messages.invalidProductText),
})
return
}
@@ -205,11 +205,16 @@
<UpToDate class="icon" />
<br />
<span class="preserve-lines text">
This organization doesn't have any projects yet.
<template v-if="isPermission(currentMember?.permissions, 1 << 4)">
Would you like to
<a class="link" @click="modal_creation?.show()">create one</a>?
<IntlFormatted :message-id="messages.noProjectsWithCreatePrompt">
<template #create-link="{ children }">
<a class="link" @click="modal_creation?.show()"
><component :is="() => normalizeChildren(children)"
/></a>
</template>
</IntlFormatted>
</template>
<template v-else>{{ formatMessage(messages.noProjects) }}</template>
</span>
</div>
</div>
@@ -233,8 +238,11 @@ import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
injectModrinthClient,
IntlFormatted,
NavTabs,
normalizeChildren,
PROJECT_DEP_MARKER_QUERY,
ProjectCard,
ProjectCardList,
@@ -266,6 +274,18 @@ type ProjectV3 = Labrinth.Projects.v3.Project & {
const vintl = useVIntl()
const { formatMessage } = vintl
const messages = defineMessages({
noProjects: {
id: 'organization.projects.none',
defaultMessage: "This organization doesn't have any projects yet.",
},
noProjectsWithCreatePrompt: {
id: 'organization.projects.none-with-create-prompt',
defaultMessage:
"This organization doesn't have any projects yet. Would you like to <create-link>create one</create-link>?",
},
})
const { formatCompactNumber } = useCompactNumber()
const auth: { user: any } & any = await useAuth()
+3 -2
View File
@@ -288,6 +288,7 @@ import {
AutoLink,
Avatar,
ButtonStyled,
commonMessages,
defineMessage,
defineMessages,
formatReportItemType,
@@ -480,7 +481,7 @@ const submitReport = async () => {
if (error instanceof Error) {
addNotification({
title: 'An error occurred',
title: formatMessage(commonMessages.errorNotificationTitle),
text: error.message,
type: 'error',
})
@@ -505,7 +506,7 @@ const submitReport = async () => {
if (error instanceof Error) {
addNotification({
title: 'An error occurred',
title: formatMessage(commonMessages.errorNotificationTitle),
text: error.message,
type: 'error',
})
+2
View File
@@ -43,8 +43,10 @@ flate2 = { workspace = true }
fs4 = { workspace = true, features = ["tokio"] }
futures = { workspace = true, features = ["alloc", "async-await"] }
futures-lite = { workspace = true }
governor = { workspace = true }
heck = { workspace = true }
hickory-resolver = { workspace = true }
httpdate = { workspace = true }
indicatif = { workspace = true, optional = true }
itertools = { workspace = true }
modrinth-content-management = { workspace = true }
+24
View File
@@ -79,6 +79,9 @@ pub enum ErrorKind {
#[error("Too many API errors, try again in {0} minutes")]
ApiIsDownError(u32),
#[error("Too many requests, retry in {}", format_seconds(*.retry_in_seconds))]
Ratelimited { retry_in_seconds: u64 },
#[error("{0}")]
LabrinthError(LabrinthError),
@@ -192,6 +195,27 @@ pub enum ErrorKind {
DiscordRichPresenceError(#[from] discord_rich_presence::error::Error),
}
fn format_seconds(seconds: u64) -> String {
let plural = |unit: u64| if unit == 1 { "" } else { "s" };
if seconds <= 59 {
return format!("{seconds} second{}", plural(seconds));
}
let minutes = seconds / 60;
let rem_seconds = seconds % 60;
if rem_seconds == 0 {
return format!("{minutes} minutes");
}
format!(
"{minutes} minute{} and {rem_seconds} second{}",
plural(minutes),
plural(rem_seconds)
)
}
#[derive(Debug)]
pub struct Error {
pub raw: Arc<ErrorKind>,
+3 -3
View File
@@ -1074,9 +1074,9 @@ fn install_error_code(
PreparingJava => "java_error",
_ => "metadata_error",
},
ErrorKind::FetchError(_) | ErrorKind::ApiIsDownError(_) => {
"network_error"
}
ErrorKind::FetchError(_)
| ErrorKind::ApiIsDownError(_)
| ErrorKind::Ratelimited { .. } => "network_error",
ErrorKind::Any(_)
if matches!(
phase,
+156 -2
View File
@@ -6,6 +6,8 @@ use crate::{ErrorKind, LabrinthError};
use bytes::Bytes;
use chrono::{DateTime, TimeDelta, Utc};
use eyre::{Context, eyre};
use governor::clock::{Clock, DefaultClock};
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use parking_lot::Mutex;
use rand::Rng;
use reqwest::Method;
@@ -14,12 +16,14 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::ffi::OsStr;
use std::future::Future;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::LazyLock;
use std::time::{self};
use std::sync::{Arc, LazyLock};
use std::time::{self, Duration, Instant, SystemTime};
use tokio::sync::Semaphore;
use tokio::{fs::File, io::AsyncReadExt, io::AsyncWriteExt};
use tracing::{debug, info};
pub const DOWNLOAD_META_HEADER: &str = "modrinth-download-meta";
@@ -188,6 +192,145 @@ static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
inner: Mutex::new(HashMap::new()),
});
const API_RETRY_AFTER_FALLBACK: Duration = Duration::from_secs(60);
// This means the unit recovery time will be:
// replenish one unit time in seconds = (60 / (units recovered per minute))
// smooth recovery time = replenish one unit time in seconds * API_RATE_LIMIT_RECOVERY_SIZE.
//
// At 20 it means (60 / 120) * 20 = 10 seconds.
const API_RATE_LIMIT_RECOVERY_SIZE: u32 = 20;
/// Implements request-rate-limit handling as well as a local rate limiter of 120 RPM + 50 burst.
struct ApiRateLimit {
block_until: Mutex<Option<Instant>>,
check_lock: Mutex<()>,
local: Arc<DefaultDirectRateLimiter>,
recovery_padding: Duration,
}
impl ApiRateLimit {
fn new() -> Self {
let quota = Quota::per_minute(NonZeroU32::new(120).unwrap())
.allow_burst(NonZeroU32::new(50).unwrap());
let recovery_size =
API_RATE_LIMIT_RECOVERY_SIZE.min(quota.burst_size().get());
Self {
block_until: Mutex::new(None),
check_lock: Mutex::new(()),
local: Arc::new(RateLimiter::direct(quota)),
recovery_padding: quota
.replenish_interval()
.saturating_mul(recovery_size.saturating_sub(1)),
}
}
fn check(&self) -> crate::Result<()> {
let _check_guard = self.check_lock.lock();
self.ensure_not_blocked()?;
if let Err(not_until) = self.local.check() {
// Adds hysteresis to the rate limiting system, ensuring recovery happens
// for longer but for more units, avoiding the "flapping" effect when running
// out of units.
let retry_after = not_until
.wait_time_from(DefaultClock::default().now())
.saturating_add(self.recovery_padding);
info!(
?retry_after,
"Hit builtin rate limiter; waiting for recovery"
);
self.block_for(retry_after);
let retry_in_seconds = self
.retry_in_seconds()
.unwrap_or_else(|| duration_seconds_ceil(retry_after));
return Err(ErrorKind::Ratelimited { retry_in_seconds }.into());
}
self.ensure_not_blocked()
}
fn handle_response(
&self,
response: &reqwest::Response,
) -> Option<ErrorKind> {
if response.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
debug!("Received 429 response; blocking");
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| parse_retry_after(value, SystemTime::now()))
.unwrap_or(API_RETRY_AFTER_FALLBACK);
self.block_for(retry_after);
Some(ErrorKind::Ratelimited {
retry_in_seconds: self.retry_in_seconds().unwrap_or(0),
})
}
fn ensure_not_blocked(&self) -> crate::Result<()> {
if let Some(retry_in_seconds) = self.retry_in_seconds() {
debug!("Hit builtin rate limiter; blocking");
return Err(ErrorKind::Ratelimited { retry_in_seconds }.into());
}
Ok(())
}
fn block_for(&self, duration: Duration) {
let Some(block_until) = Instant::now().checked_add(duration) else {
return;
};
let mut current_block = self.block_until.lock();
if current_block.is_none_or(|current| current < block_until) {
*current_block = Some(block_until);
}
}
fn retry_in_seconds(&self) -> Option<u64> {
let mut block_until = self.block_until.lock();
let remaining = (*block_until)?.checked_duration_since(Instant::now());
if let Some(remaining) = remaining
&& !remaining.is_zero()
{
return Some(duration_seconds_ceil(remaining));
}
*block_until = None;
None
}
}
static GLOBAL_API_RATE_LIMIT: LazyLock<ApiRateLimit> =
LazyLock::new(ApiRateLimit::new);
fn parse_retry_after(value: &str, now: SystemTime) -> Option<Duration> {
if let Ok(seconds) = value.parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
let retry_at = httpdate::parse_http_date(value).ok()?;
Some(retry_at.duration_since(now).unwrap_or(Duration::ZERO))
}
fn duration_seconds_ceil(duration: Duration) -> u64 {
duration
.as_secs()
.saturating_add(u64::from(duration.subsec_nanos() > 0))
}
fn reqwest_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.connect_timeout(time::Duration::from_secs(15))
@@ -452,6 +595,10 @@ async fn fetch_advanced_with_client_and_progress(
.map(|m| (DOWNLOAD_META_HEADER.to_string(), m.to_header_value()));
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
if is_api_url {
GLOBAL_API_RATE_LIMIT.check()?;
}
if let Some(fence_key) = fence_key
&& GLOBAL_FETCH_FENCE.is_blocked(fence_key)
{
@@ -483,6 +630,13 @@ async fn fetch_advanced_with_client_and_progress(
let result = req.send().await;
match result {
Ok(resp) => {
if is_api_url
&& let Some(error) =
GLOBAL_API_RATE_LIMIT.handle_response(&resp)
{
return Err(error.into());
}
if resp.status().is_server_error() {
if let Some(fence_key) = fence_key {
GLOBAL_FETCH_FENCE.record_fail(fence_key);
+35 -3
View File
@@ -11,12 +11,44 @@ export type VersionEntry = {
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-16T19:36:45+00:00`,
date: `2026-07-21T18:43:00+00:00`,
product: 'web',
body: `## Changed
- Replaced default Aditude consent pop-up with a custom one with more accurate phrasing.
- Updated page headers to use a consistent design style.
- Updated translations
## Fixed
- Fixed analytics tooltip not pinned after mobile chart drag
- Fixed extra spacing sometimes on the download modal.`,
},
{
date: `2026-07-21T18:43:00+00:00`,
product: 'app',
version: '0.15.12',
body: `## Fixed
version: '0.15.14',
body: `## Added
- Added browse page header onto project pages when going into a project page from search.
- Added support for the [Ears](https://modrinth.com/mod/ears) mod in the Skin selector.
## Changed
- Replaced default Aditude consent pop-up with a custom one with more accurate phrasing.
- Updated page headers to use a consistent design style.
- Updated translations
## Fixed
- Fixed ratelimits not being handled properly.
- Fixed UV spill issues causing some tiny parts of the skin texture to appear as floating lines.
- Fixed issue with some modpacks failing to install due to file mismatches.
- Fixed hardcore worlds generated in 26.1+ not being recognised as hardcore in the Jump back in and Worlds tab.`,
},
{
date: `2026-07-21T18:43:00+00:00`,
product: 'hosting',
body: `## Added
- Added browse page header onto project pages when going into a project page from search.
## Changed
- Updated translations`,
},
{
date: `2026-07-14T22:14:08+00:00`,
@@ -326,9 +326,11 @@ export default function () {
}))(state)
})
.fix(
//TODO chyz think of some way to have initial values actually be reflected in state without having to store them
fix().project((patch, state) => {
const slug = state['correct-slug'] as string
if (!slug) return
const slug =
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value
if (!slug || slug === project.value.slug) return
patch.slug = slug
}),
),
@@ -17,7 +17,7 @@
@mouseleave="setNotificationTimer(item)"
>
<div
class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl border border-solid border-surface-5"
class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised border border-solid border-surface-5"
:class="item.containerClass"
>
<div
@@ -25,6 +25,7 @@ export interface UseBrowseSearchOptions {
}>
providedFilters?: ComputedRef<FilterValue[]>
environmentOverride?: ComputedRef<EnvironmentSearchOverride | undefined>
active?: ComputedRef<boolean>
search: (params: string) => Promise<BrowseSearchResponse>
persistentQueryParams: string[]
getExtraQueryParams?: () => Record<string, string | undefined>
@@ -74,6 +75,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
debug('init, projectType:', options.projectType.value)
const active = computed(() => options.active?.value ?? true)
const projectTypes = computed(() => [options.projectType.value] as ProjectType[])
const isServerType = computed(() => options.projectType.value === 'server')
@@ -184,6 +186,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
let searchVersion = 0
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
function clearSearchDebounce() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? [])
watch(
@@ -209,13 +218,27 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
from: oldVal?.substring(0, 80),
to: newVal?.substring(0, 80),
})
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
clearSearchDebounce()
if (!active.value) {
return
}
searchDebounceTimer = setTimeout(() => {
refreshSearch()
}, 200)
})
watch(active, (isActive, wasActive) => {
clearSearchDebounce()
if (isActive && wasActive === false) {
void refreshSearch()
}
})
async function refreshSearch() {
if (!active.value) {
return
}
const version = ++searchVersion
debug('refreshSearch start', {
version,
@@ -233,6 +256,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
try {
const response = await options.search(effectiveRequestParams.value)
if (!active.value) {
return
}
if (version !== searchVersion) {
debug('refreshSearch stale, discarding', { version, current: searchVersion })
return
@@ -263,6 +290,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
}
function updateUrlParams() {
if (!active.value) {
return
}
debug('updateUrlParams', { path: route.path })
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}
@@ -121,12 +121,110 @@
:class="['server-stagger-item', containedLayout ? 'shrink-0' : '', { 'mt-4': isNuxt }]"
:style="{ '--si': 0 }"
>
<PageHeader
:title="serverData?.name || 'Server'"
:leading="serverHeaderLeading"
:metadata="serverHeaderMetadata"
:actions="serverHeaderActions"
/>
<PageHeader :title="serverData?.name || 'Server'">
<template #leading>
<ServerIcon
:image="serverHeaderImage"
:class="isNuxt ? 'size-20 !rounded-2xl' : 'size-16 !rounded-xl'"
/>
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataItem
v-if="serverData.flows?.intro"
:icon="SettingsIcon"
class="font-semibold"
>
Configuring server...
</PageHeaderMetadataItem>
<template v-else>
<PageHeaderMetadataItem
v-if="serverData.loader"
:icon="LoaderIcon"
:icon-props="{ loader: serverData.loader }"
>
{{ formatLoaderLabel(serverData.loader) }} {{ serverData.mc_version }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="serverData.net?.domain && !serverPreferences.hideSubdomainLabel"
:icon="LinkIcon"
tooltip="Copy server address"
:action="copyServerAddress"
>
{{ serverData.net.domain }}.modrinth.gg
</PageHeaderMetadataItem>
<PageHeaderMetadataItem v-if="showServerUptime" :icon="TimerIcon">
{{ formattedUptime }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="serverProject"
:to="serverProjectLink"
class="!text-primary"
>
Linked to
<Avatar :src="serverProject.icon_url" :alt="serverProject.title" size="24px" />
{{ serverProject.title }}
</PageHeaderMetadataItem>
</template>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<PanelServerActionButton :disabled="!!installError" />
<Tooltip
theme="dismissable-prompt"
:triggers="[]"
:shown="showSettingsHint"
:auto-hide="false"
placement="bottom-end"
>
<ButtonStyled circular size="large">
<button
v-tooltip="showSettingsHint ? undefined : 'Server settings'"
type="button"
aria-label="Server settings"
@click="handleOpenServerSettings"
>
<SettingsIcon />
</button>
</ButtonStyled>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(settingsHintMessages.title) }}
</h3>
<ButtonStyled size="small" circular>
<button
type="button"
:aria-label="formatMessage(settingsHintMessages.dismiss)"
@click="dismissSettingsHint"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(settingsHintMessages.description) }}
</p>
</div>
</template>
</Tooltip>
<ButtonStyled circular type="transparent" size="large">
<TeleportOverflowMenu
:options="serverMenuOptions"
:disabled="!!installError"
aria-label="More server options"
>
<MoreVerticalIcon aria-hidden="true" />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
</div>
<ServerOnboardingPanelPage v-if="isOnboarding" :browse-modpacks="handleBrowseModpacks" />
@@ -322,18 +420,25 @@ import {
TransferIcon,
TriangleAlertIcon,
UsersIcon,
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage, useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ErrorInformationCard from '#ui/components/base/ErrorInformationCard.vue'
import NavTabs from '#ui/components/base/NavTabs.vue'
import PageHeader from '#ui/components/base/page-header/index.vue'
import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.vue'
import PageHeaderMetadataItem from '#ui/components/base/page-header/metadata/page-header-metadata-item.vue'
import PageHeaderActions from '#ui/components/base/page-header/page-header-actions.vue'
import ServerNotice from '#ui/components/base/ServerNotice.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import ServerPanelAdmonitions from '#ui/components/servers/admonitions/ServerPanelAdmonitions.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
@@ -659,19 +764,11 @@ const {
onStateEvent,
})
const serverHeaderLeading = computed(() => [
{
id: 'server-icon',
type: 'component' as const,
component: ServerIcon,
componentProps: {
image: serverImage.value,
},
class: isNuxt.value ? 'size-20 !rounded-2xl' : 'size-16 !rounded-xl',
},
])
const serverHeaderImage = computed(() =>
serverData.value?.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverImage.value,
)
const showServerUptime = computed(() => props.showUptime && uptimeSeconds.value > 0)
const showServerUptime = computed(() => props.showUptime && serverPowerState.value === 'running')
const formattedUptime = computed(() => formatUptime(uptimeSeconds.value))
@@ -680,113 +777,19 @@ const serverProjectLink = computed(() => {
return `/project/${serverProject.value.slug ?? serverProject.value.id}`
})
const serverHeaderMetadata = computed(() => {
const server = serverData.value
const items = []
if (server?.flows?.intro) {
items.push({
id: 'intro',
icon: SettingsIcon,
label: 'Configuring server...',
class: 'font-semibold',
})
return items
}
if (server?.loader) {
items.push({
id: 'loader',
icon: LoaderIcon,
iconProps: {
loader: server.loader,
},
label: `${formatLoaderLabel(server.loader)} ${server.mc_version}`,
})
}
if (server?.net?.domain && !serverPreferences.value.hideSubdomainLabel) {
items.push({
id: 'server-address',
icon: LinkIcon,
label: `${server.net.domain}.modrinth.gg`,
tooltip: 'Copy server address',
onClick: copyServerAddress,
})
}
if (showServerUptime.value) {
items.push({
id: 'uptime',
icon: TimerIcon,
label: formattedUptime.value,
})
}
if (serverProject.value) {
items.push({
id: 'linked-project',
label: 'Linked to',
value: serverProject.value.title,
valueClass: 'text-primary',
avatarSrc: serverProject.value.icon_url,
avatarAlt: serverProject.value.title,
to: serverProjectLink.value,
})
}
return items
})
const serverHeaderActions = computed(() => [
const serverMenuOptions = computed(() => [
{
id: 'server-power',
label: 'Server power',
kind: 'component' as const,
component: PanelServerActionButton,
componentProps: {
disabled: !!installError.value,
},
id: 'all-servers',
label: 'All servers',
icon: ServerAssetIcon,
action: () => void router.push('/hosting/manage'),
},
{
id: 'settings',
label: 'Server settings',
icon: SettingsIcon,
labelHidden: true,
circular: true,
tooltip: showSettingsHint.value ? undefined : 'Server settings',
onClick: () => openServerSettingsModal(),
prompt: {
title: formatMessage(settingsHintMessages.title),
description: formatMessage(settingsHintMessages.description),
dismissLabel: formatMessage(settingsHintMessages.dismiss),
shown: showSettingsHint.value,
placement: 'bottom-end',
onDismiss: dismissSettingsHint,
},
},
{
id: 'more',
label: 'More server options',
icon: MoreVerticalIcon,
labelHidden: true,
circular: true,
type: 'transparent' as const,
menuActions: [
{
id: 'all-servers',
label: 'All servers',
icon: ServerAssetIcon,
action: () => router.push('/hosting/manage'),
},
{
id: 'copy-id',
label: 'Copy ID',
icon: CopyIcon,
action: copyServerId,
shown: props.showCopyIdAction,
},
],
id: 'copy-id',
label: 'Copy ID',
icon: CopyIcon,
action: copyServerId,
shown: props.showCopyIdAction,
},
])
@@ -819,6 +822,11 @@ function copyServerId() {
void navigator.clipboard.writeText(props.serverId)
}
function handleOpenServerSettings() {
openServerSettingsModal()
dismissSettingsHint()
}
const isUploading = computed(() => uploadState.value.isUploading)
const canSetup = computed(() =>
hasServerPermission(serverData.value?.current_user_permissions ?? 0, 'SETUP'),