feat: project page header when visiting from browse (#6630)

* feat: project page header when visiting from browse

* fix: lint
This commit is contained in:
Calum H.
2026-07-21 11:34:27 -07:00
committed by GitHub
parent e8f8722b70
commit ff1f86a0f4
34 changed files with 272 additions and 258 deletions
@@ -455,9 +455,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "العودة إلى الاستكشاف" "message": "العودة إلى الاستكشاف"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "تثبيت المحتويات الى النموذج"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "جميع الإصدارات" "message": "جميع الإصدارات"
}, },
@@ -392,9 +392,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Zpět k Objevování" "message": "Zpět k Objevování"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Nainstalovat obsah do instance"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Všechny verze" "message": "Všechny verze"
}, },
@@ -296,9 +296,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Tilbage til udforsk" "message": "Tilbage til udforsk"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Installer indhold til instance"
},
"app.project.versions.already-installed": { "app.project.versions.already-installed": {
"message": "Allerede installeret" "message": "Allerede installeret"
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Zurück zu Entdecken" "message": "Zurück zu Entdecken"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Inhalt in Instanz installieren"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Alle Versionen" "message": "Alle Versionen"
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Zurück zum Entdecken" "message": "Zurück zum Entdecken"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Inhalt in Instanz installieren"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Alle Versionen" "message": "Alle Versionen"
}, },
@@ -494,9 +494,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Back to discover" "message": "Back to discover"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Install content to instance"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "All versions" "message": "All versions"
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Volver al explorador" "message": "Volver al explorador"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Instalar contenido a la instancia"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Todas las versiones" "message": "Todas las versiones"
}, },
@@ -296,9 +296,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Volver a descubrir" "message": "Volver a descubrir"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Instalar contenido"
},
"app.settings.developer-mode-enabled": { "app.settings.developer-mode-enabled": {
"message": "Modo desarrollador activado." "message": "Modo desarrollador activado."
}, },
@@ -377,9 +377,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Takaisin sisällön löytöön" "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": { "app.settings.developer-mode-enabled": {
"message": "Kehittäjätila käytössä." "message": "Kehittäjätila käytössä."
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Retour vers la page découvrir" "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": { "app.project.version.all-versions": {
"message": "Toutes les versions" "message": "Toutes les versions"
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Vissza a böngészéshez" "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": { "app.project.version.all-versions": {
"message": "Összes verzió" "message": "Összes verzió"
}, },
@@ -272,9 +272,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Kembali ke halaman pencarian" "message": "Kembali ke halaman pencarian"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Pasang konten ke instans"
},
"app.settings.developer-mode-enabled": { "app.settings.developer-mode-enabled": {
"message": "Mode pengembang dihidupkan." "message": "Mode pengembang dihidupkan."
}, },
@@ -476,9 +476,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Torna a Esplora" "message": "Torna a Esplora"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Installa il contenuto nell'istanza"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Tutte le versioni" "message": "Tutte le versioni"
}, },
@@ -479,21 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "탐색으로 돌아가기" "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": { "app.settings.developer-mode-enabled": {
"message": "개발자 모드가 활성화되었습니다." "message": "개발자 모드가 활성화되었습니다."
}, },
@@ -287,9 +287,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Kembali menemukan" "message": "Kembali menemukan"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Pasang kandungan ke dalam pemasangan"
},
"app.settings.developer-mode-enabled": { "app.settings.developer-mode-enabled": {
"message": "Mod pembangun didayakan." "message": "Mod pembangun didayakan."
}, },
@@ -479,21 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Terug naar Ontdekken" "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": { "app.settings.developer-mode-enabled": {
"message": "Ontwikkelaarsmodus ingeschakeld." "message": "Ontwikkelaarsmodus ingeschakeld."
}, },
@@ -476,9 +476,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Powróć do odkrywania" "message": "Powróć do odkrywania"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Zainstaluj zasoby do instancji"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Wszystkie wersje" "message": "Wszystkie wersje"
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Voltar a descobrir" "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": { "app.project.version.all-versions": {
"message": "Todas as versões" "message": "Todas as versões"
}, },
@@ -473,9 +473,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Вернуться к поиску" "message": "Вернуться к поиску"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Установка контента в сборку"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Все версии" "message": "Все версии"
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Nazad da otkrivanje" "message": "Nazad da otkrivanje"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Instaliraj sadržaj na instancu"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Sve verzije" "message": "Sve verzije"
}, },
@@ -446,9 +446,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Tillbaka till upptäck" "message": "Tillbaka till upptäck"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "Installera innehåll till instans"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "Alla versioner" "message": "Alla versioner"
}, },
@@ -272,9 +272,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "กลับไปที่หน้าค้นหา" "message": "กลับไปที่หน้าค้นหา"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "ติดตั้งเนื้อหาลงสู่อินสแตนซ์"
},
"app.settings.developer-mode-enabled": { "app.settings.developer-mode-enabled": {
"message": "โหมดนักพัฒนาเปิดอยู่" "message": "โหมดนักพัฒนาเปิดอยู่"
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Keşfete geri dön" "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": { "app.project.version.all-versions": {
"message": "Tüm sürümler" "message": "Tüm sürümler"
}, },
@@ -479,21 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Повернутися до дослідження" "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": { "app.settings.developer-mode-enabled": {
"message": "Увімкнено режим розробника." "message": "Увімкнено режим розробника."
}, },
@@ -329,21 +329,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "Quay lại trang Khám Phá" "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": { "app.settings.developer-mode-enabled": {
"message": "Chế độ nhà phát triển đã được bật." "message": "Chế độ nhà phát triển đã được bật."
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "返回发现页" "message": "返回发现页"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "将内容安装到实例中"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "所有版本" "message": "所有版本"
}, },
@@ -479,9 +479,6 @@
"app.project.install-context.back-to-browse": { "app.project.install-context.back-to-browse": {
"message": "返回瀏覽" "message": "返回瀏覽"
}, },
"app.project.install-context.install-content-to-instance": {
"message": "將內容安裝至實例"
},
"app.project.version.all-versions": { "app.project.version.all-versions": {
"message": "所有版本" "message": "所有版本"
}, },
+9 -1
View File
@@ -72,6 +72,7 @@ const debugLog = useDebugLogger('Browse')
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const themeStore = useTheming() const themeStore = useTheming()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null) const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef }) const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
provideServerInstallContent(serverInstallContent) provideServerInstallContent(serverInstallContent)
@@ -490,6 +491,9 @@ function resetInstanceContext() {
watch( watch(
() => route.params.projectType as ProjectType, () => route.params.projectType as ProjectType,
async (newType) => { async (newType) => {
if (!browseRouteActive.value) {
return
}
if (isSetupServerContext.value) { if (isSetupServerContext.value) {
enforceSetupModpackRoute(newType) enforceSetupModpackRoute(newType)
if (newType !== 'modpack') return if (newType !== 'modpack') return
@@ -960,6 +964,7 @@ const lockedFilterMessages = computed(() => ({
const searchState = useBrowseSearch({ const searchState = useBrowseSearch({
projectType, projectType,
tags, tags,
active: browseRouteActive,
providedFilters: combinedProvidedFilters, providedFilters: combinedProvidedFilters,
search, search,
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'], persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
@@ -1035,6 +1040,9 @@ onUnmounted(() => {
}) })
function getProjectBrowseQuery() { function getProjectBrowseQuery() {
if (!browseRouteActive.value) {
return undefined
}
if (!installContext.value) return undefined if (!installContext.value) return undefined
return { return {
...route.query, ...route.query,
@@ -1139,7 +1147,7 @@ provideBrowseManager({
@browse-modpacks="() => {}" @browse-modpacks="() => {}"
@create="handleServerModpackFlowCreate" @create="handleServerModpackFlowCreate"
/> />
<Teleport to="#sidebar-teleport-target"> <Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
<BrowseSidebar /> <BrowseSidebar />
</Teleport> </Teleport>
</div> </div>
@@ -320,10 +320,6 @@ const messages = defineMessages({
id: 'app.project.install-button.already-installed', id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed', defaultMessage: 'This project is already installed',
}, },
installContentToInstance: {
id: 'app.project.install-context.install-content-to-instance',
defaultMessage: 'Install content to instance',
},
switchVersion: { switchVersion: {
id: 'app.project.install-button.switch-version', id: 'app.project.install-button.switch-version',
defaultMessage: 'Switch version', defaultMessage: 'Switch version',
@@ -450,7 +446,7 @@ const projectInstallContext = computed(() => {
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null, iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
backUrl: projectBrowseBackUrl.value, backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse), backLabel: formatMessage(messages.backToBrowse),
heading: formatMessage(messages.installContentToInstance), heading: formatMessage(commonMessages.installingContentLabel),
} }
} }
@@ -3476,6 +3476,9 @@
"project.gallery.title": { "project.gallery.title": {
"message": "Gallery" "message": "Gallery"
}, },
"project.install-context.back-to-discover": {
"message": "Back to discover"
},
"project.license.error": { "project.license.error": {
"message": "License text could not be retrieved." "message": "License text could not be retrieved."
}, },
+109 -6
View File
@@ -97,6 +97,22 @@
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" /> <CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
<ModpackScanModal ref="scanModal" :project_id="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 <div
class="new-page sidebar" class="new-page sidebar"
:class="{ :class="{
@@ -111,7 +127,10 @@
!flags.alwaysShowChecklistAsPopup, !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"> <div class="mb-6">
<ModerationProjectNags <ModerationProjectNags
v-if=" v-if="
@@ -616,6 +635,7 @@ import {
import { import {
Admonition, Admonition,
Avatar, Avatar,
BrowseInstallHeader,
ButtonStyled, ButtonStyled,
commonMessages, commonMessages,
defineMessages, defineMessages,
@@ -636,11 +656,13 @@ import {
ProjectSidebarServerInfo, ProjectSidebarServerInfo,
ProjectSidebarTags, ProjectSidebarTags,
provideProjectPageContext, provideProjectPageContext,
SelectedProjectsFloatingBar,
TeleportOverflowMenu, TeleportOverflowMenu,
useDebugLogger, useDebugLogger,
useFormatDateTime, useFormatDateTime,
useFormatPrice, useFormatPrice,
useRelativeTime, useRelativeTime,
useStickyObserver,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { capitalizeString, formatProjectType, renderString } from '@modrinth/utils' import { capitalizeString, formatProjectType, renderString } from '@modrinth/utils'
@@ -663,6 +685,7 @@ import { getSignInRouteObj } from '~/composables/auth.ts'
import { saveFeatureFlags } from '~/composables/featureFlags.ts' import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project' import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version' import { versionQueryOptions } from '~/composables/queries/version'
import { useServerInstallContent } from '~/composables/use-server-install-content'
import { userCollectProject, userFollowProject } from '~/composables/user.js' import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { injectCurrentProjectId } from '~/providers/current-project.ts' import { injectCurrentProjectId } from '~/providers/current-project.ts'
import { loadChecklistState } from '~/services/moderation-checklist-storage.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 projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null) const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const stickyInstallHeaderRef = ref(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'ProjectInstallHeader',
)
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal') const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
@@ -780,6 +808,10 @@ const messages = defineMessages({
defaultMessage: defaultMessage:
'{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project.', '{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: { changelogTab: {
id: 'project.navigation.changelog', id: 'project.navigation.changelog',
defaultMessage: '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) // Use actual project ID for dependent queries (ensures cache consistency)
const projectId = computed(() => projectRaw.value?.id) const projectId = computed(() => projectRaw.value?.id)
@@ -2067,6 +2144,32 @@ function triggerDownloadAnimation() {
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500) 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) { async function deleteVersion(id) {
if (!id) return if (!id) return
@@ -2091,16 +2194,16 @@ const navLinks = computed(() => {
return [ return [
{ {
label: formatMessage(messages.descriptionTab), label: formatMessage(messages.descriptionTab),
href: projectUrl, href: withInstallContextQuery(projectUrl),
}, },
{ {
label: formatMessage(messages.galleryTab), label: formatMessage(messages.galleryTab),
href: `${projectUrl}/gallery`, href: withInstallContextQuery(`${projectUrl}/gallery`),
shown: galleryCount > 0 || !!currentMember.value, shown: galleryCount > 0 || !!currentMember.value,
}, },
{ {
label: formatMessage(messages.changelogTab), label: formatMessage(messages.changelogTab),
href: `${projectUrl}/changelog`, href: withInstallContextQuery(`${projectUrl}/changelog`),
shown: shown:
hasVersions.value && hasVersions.value &&
projectV3Loaded.value && projectV3Loaded.value &&
@@ -2109,7 +2212,7 @@ const navLinks = computed(() => {
}, },
{ {
label: formatMessage(messages.versionsTab), label: formatMessage(messages.versionsTab),
href: `${projectUrl}/versions`, href: withInstallContextQuery(`${projectUrl}/versions`),
shown: shown:
(hasVersions.value || !!currentMember.value) && (hasVersions.value || !!currentMember.value) &&
projectV3Loaded.value && projectV3Loaded.value &&
@@ -2119,7 +2222,7 @@ const navLinks = computed(() => {
}, },
{ {
label: formatMessage(messages.moderationTab), label: formatMessage(messages.moderationTab),
href: `${projectUrl}/moderation`, href: withInstallContextQuery(`${projectUrl}/moderation`),
shown: !!currentMember.value, shown: !!currentMember.value,
}, },
] ]
+7 -10
View File
@@ -4,7 +4,6 @@ import { commonProjectTypeCategoryMessages, NavTabs, useVIntl } from '@modrinth/
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const flags = useFeatureFlags() const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const route = useRoute() const route = useRoute()
const allowTabChanging = computed(() => !route.query.sid) const allowTabChanging = computed(() => !route.query.sid)
@@ -48,15 +47,13 @@ const selectableProjectTypes = [
] ]
</script> </script>
<template> <template>
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }"> <div class="mx-auto box-border flex w-full max-w-[1280px] flex-col gap-4 px-6 pb-6">
<section class="normal-page__header mb-4 flex flex-col gap-4"> <NavTabs
<NavTabs v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
v-if="!flags.projectTypesPrimaryNav && allowTabChanging" :links="selectableProjectTypes"
:links="selectableProjectTypes" replace
replace class="hidden md:flex"
class="hidden md:flex" />
/>
</section>
<NuxtPage /> <NuxtPage />
</div> </div>
</template> </template>
+111 -106
View File
@@ -33,6 +33,7 @@ import { cycleValue } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import { useTimeoutFn } from '@vueuse/core' import { useTimeoutFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import type { LocationQueryRaw } from 'vue-router'
import LogoAnimated from '~/components/brand/LogoAnimated.vue' import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue' import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
@@ -55,10 +56,6 @@ const client = injectModrinthClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const filtersMenuOpen = ref(false) const filtersMenuOpen = ref(false)
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
useStickyObserver(stickyInstallHeaderRef, 'DiscoverInstallHeader')
const route = useRoute() const route = useRoute()
const cosmetics = useCosmetics() const cosmetics = useCosmetics()
@@ -202,49 +199,83 @@ function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject
return undefined 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', { debug('search() called', {
requestParams: requestParams.substring(0, 100), requestParams: requestParams.substring(0, 100),
isServer: isServerType.value, isServer: isServerType.value,
projectTypeId: projectTypeId.value, projectTypeId: projectTypeId.value,
}) })
const config = useRuntimeConfig()
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (isServerType.value) { const raw = await client.request<Labrinth.Search.v3.SearchResults>('/search', {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3') api: 'labrinth',
} version: 3,
method: 'GET',
const url = `${base}search${requestParams}` params: Object.fromEntries(new URLSearchParams(requestParams.replace(/^\?/, ''))),
debug('search() fetching:', url.substring(0, 120)) headers: withLabrinthCanaryHeader(),
})
const raw = await $fetch<Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults>(
url,
{
headers: withLabrinthCanaryHeader(),
},
)
debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length }) debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length })
if ('hits_per_page' in raw) { if (isServerType.value) {
// v3 response (servers)
return { return {
projectHits: [], projectHits: [],
serverHits: raw.hits as Labrinth.Search.v3.ResultSearchProject[], serverHits: raw.hits,
total_hits: raw.total_hits, total_hits: raw.total_hits,
per_page: raw.hits_per_page, per_page: raw.hits_per_page,
} }
} }
return { return {
projectHits: raw.hits as Labrinth.Search.v2.ResultSearchProject[], projectHits: raw.hits.map(mapV3ProjectHit),
serverHits: [], serverHits: [],
total_hits: raw.total_hits, 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( function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject, result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string, currentProjectType: string,
@@ -424,7 +455,7 @@ watch(
) )
debug('calling initial refreshSearch') debug('calling initial refreshSearch')
searchState.refreshSearch() await searchState.refreshSearch()
const ogTitle = computed(() => const ogTitle = computed(() =>
searchState.query.value searchState.query.value
@@ -462,9 +493,11 @@ provideBrowseManager({
projectType: projectTypeId, projectType: projectTypeId,
...searchState, ...searchState,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => 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) => getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) =>
`/server/${result.slug ?? result.project_id}`, withHostingContext(`/server/${result.slug ?? result.project_id}`),
selectableProjectTypes: computed(() => []), selectableProjectTypes: computed(() => []),
showProjectTypeTabs: computed(() => false), showProjectTypeTabs: computed(() => false),
variant: 'web', variant: 'web',
@@ -504,25 +537,41 @@ provideBrowseManager({
}, },
loadingComponent: LogoAnimated, loadingComponent: LogoAnimated,
}) })
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'DiscoverInstallHeader',
)
</script> </script>
<template> <template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport"> <Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div> <div class="search-background"></div>
</Teleport> </Teleport>
<div <div
v-if="installContext" v-if="installContext"
ref="stickyInstallHeaderRef" 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> </div>
<SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" /> <SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" />
<aside class="normal-page__sidebar" :aria-label="formatMessage(commonMessages.filtersLabel)">
<AdPlaceholder v-if="!auth.user && !serverData" /> <div
<BrowseSidebar /> class="grid min-w-0 gap-3"
</aside> :class="
<section class="normal-page__content"> cosmetics.rightSearchLayout
<div class="flex flex-col gap-3"> ? '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> <BrowsePageLayout>
<template #display-mode-icon> <template #display-mode-icon>
<GridIcon v-if="resultsDisplayMode === 'grid'" /> <GridIcon v-if="resultsDisplayMode === 'grid'" />
@@ -530,78 +579,34 @@ provideBrowseManager({
<ListIcon v-else /> <ListIcon v-else />
</template> </template>
</BrowsePageLayout> </BrowsePageLayout>
<CreationFlowModal </section>
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef" <aside
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'" class="min-w-0"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']" :class="cosmetics.rightSearchLayout ? 'lg:order-2' : 'lg:order-1'"
:show-snapshot-toggle="true" :aria-label="formatMessage(commonMessages.filtersLabel)"
:on-back="onOnboardingBack" >
@hide="onOnboardingHide" <BrowseSidebar>
@browse-modpacks="() => {}" <template #prepend>
@create="onModpackFlowCreate" <AdPlaceholder v-if="!auth.user && !serverData" />
/> </template>
</div> </BrowseSidebar>
</section> </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> </template>
<style lang="scss" scoped> <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 { .search-background {
width: 100%; width: 100%;
height: 20rem; height: 20rem;
@@ -25,6 +25,7 @@ export interface UseBrowseSearchOptions {
}> }>
providedFilters?: ComputedRef<FilterValue[]> providedFilters?: ComputedRef<FilterValue[]>
environmentOverride?: ComputedRef<EnvironmentSearchOverride | undefined> environmentOverride?: ComputedRef<EnvironmentSearchOverride | undefined>
active?: ComputedRef<boolean>
search: (params: string) => Promise<BrowseSearchResponse> search: (params: string) => Promise<BrowseSearchResponse>
persistentQueryParams: string[] persistentQueryParams: string[]
getExtraQueryParams?: () => Record<string, string | undefined> getExtraQueryParams?: () => Record<string, string | undefined>
@@ -74,6 +75,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
debug('init, projectType:', options.projectType.value) debug('init, projectType:', options.projectType.value)
const active = computed(() => options.active?.value ?? true)
const projectTypes = computed(() => [options.projectType.value] as ProjectType[]) const projectTypes = computed(() => [options.projectType.value] as ProjectType[])
const isServerType = computed(() => options.projectType.value === 'server') const isServerType = computed(() => options.projectType.value === 'server')
@@ -184,6 +186,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
let searchVersion = 0 let searchVersion = 0
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
function clearSearchDebounce() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? []) const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? [])
watch( watch(
@@ -209,13 +218,27 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
from: oldVal?.substring(0, 80), from: oldVal?.substring(0, 80),
to: newVal?.substring(0, 80), to: newVal?.substring(0, 80),
}) })
if (searchDebounceTimer) clearTimeout(searchDebounceTimer) clearSearchDebounce()
if (!active.value) {
return
}
searchDebounceTimer = setTimeout(() => { searchDebounceTimer = setTimeout(() => {
refreshSearch() refreshSearch()
}, 200) }, 200)
}) })
watch(active, (isActive, wasActive) => {
clearSearchDebounce()
if (isActive && wasActive === false) {
void refreshSearch()
}
})
async function refreshSearch() { async function refreshSearch() {
if (!active.value) {
return
}
const version = ++searchVersion const version = ++searchVersion
debug('refreshSearch start', { debug('refreshSearch start', {
version, version,
@@ -233,6 +256,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
try { try {
const response = await options.search(effectiveRequestParams.value) const response = await options.search(effectiveRequestParams.value)
if (!active.value) {
return
}
if (version !== searchVersion) { if (version !== searchVersion) {
debug('refreshSearch stale, discarding', { version, current: searchVersion }) debug('refreshSearch stale, discarding', { version, current: searchVersion })
return return
@@ -263,6 +290,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
} }
function updateUrlParams() { function updateUrlParams() {
if (!active.value) {
return
}
debug('updateUrlParams', { path: route.path }) debug('updateUrlParams', { path: route.path })
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {} const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}