feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 13:24:32 -07:00
committed by GitHub
co-authored by tdgao Artyom Ezri
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions
+357 -330
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import type { Archon, Labrinth } from '@modrinth/api-client'
import {
BookmarkIcon,
CheckIcon,
@@ -12,6 +12,7 @@ import {
InfoIcon,
LeftArrowIcon,
ListIcon,
MinecraftServerIcon,
MoreVerticalIcon,
SearchIcon,
XIcon,
@@ -20,6 +21,8 @@ import {
Avatar,
ButtonStyled,
Checkbox,
type CreationFlowContextValue,
CreationFlowModal,
defineMessages,
DropdownSelect,
injectModrinthClient,
@@ -31,30 +34,31 @@ import {
SearchSidebarFilter,
type SortType,
StyledInput,
Toggle,
useDebugLogger,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { capitalizeString, cycleValue } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useThrottleFn, useTimeoutFn } from '@vueuse/core'
import { computed, type Reactive, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import { projectQueryOptions } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('Discover')
const client = injectModrinthClient()
const queryClient = useQueryClient()
const filtersMenuOpen = ref(false)
const route = useNativeRoute()
const router = useNativeRouter()
const route = useRoute()
const router = useRouter()
const cosmetics = useCosmetics()
const tags = useGeneratedState()
@@ -62,8 +66,6 @@ const flags = useFeatureFlags()
const auth = await useAuth()
const { handleError } = injectNotificationManager()
const modrinthClient = injectModrinthClient()
const queryClient = useQueryClient()
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
const HOVER_DURATION_TO_PREFETCH_MS = 500
@@ -71,14 +73,20 @@ const HOVER_DURATION_TO_PREFETCH_MS = 500
const handleProjectMouseEnter = (result: Labrinth.Search.v2.ResultSearchProject) => {
const slug = result.slug || result.project_id
prefetchTimeout = useTimeoutFn(
() => queryClient.prefetchQuery(projectQueryOptions.v2(slug, modrinthClient)),
() => {
queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, client))
},
HOVER_DURATION_TO_PREFETCH_MS,
{ immediate: false },
)
prefetchTimeout.start()
}
const handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const _handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const slug = result.slug || result.project_id
prefetchTimeout = useTimeoutFn(
@@ -105,10 +113,6 @@ const currentType = computed(() =>
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
)
watch(currentType, (newType) => {
console.log('currentType changed:', newType)
})
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
const projectTypes = computed(() => (projectType.value ? [projectType.value.id] : []))
@@ -121,58 +125,104 @@ const resultsDisplayMode = computed<DisplayMode>(() =>
: 'list',
)
const server = ref<Reactive<ModrinthServer>>()
const serverHideInstalled = ref(false)
const eraseDataOnInstall = ref(false)
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || undefined)
debug('currentServerId:', currentServerId.value)
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
async function updateServerContext() {
const serverId = queryAsString(route.query.sid)
if (!serverId) {
server.value = undefined
return
}
try {
if (!auth.value.user) {
router.push('/auth/sign-in?redirect=' + encodeURIComponent(route.fullPath))
return
}
if (!server.value || server.value.serverId !== serverId) {
server.value = await useModrinthServers(serverId, ['general', 'content'])
}
if (route.query.shi && projectType.value?.id !== 'modpack' && server.value) {
serverHideInstalled.value = route.query.shi === 'true'
}
} catch (error) {
console.error('Failed to load server context:', error)
server.value = undefined
}
}
if (import.meta.client && route.query.sid) {
updateServerContext().catch((error) => {
console.error('Failed to initialize server context:', error)
})
}
watch(
() => route.query.sid,
() => {
updateServerContext().catch((error) => {
console.error('Failed to update server context:', error)
})
const {
data: serverData,
isLoading: serverDataLoading,
error: serverDataError,
} = useQuery({
queryKey: computed(() => ['servers', 'detail', currentServerId.value] as const),
queryFn: () => {
debug('serverData queryFn firing for:', currentServerId.value)
return client.archon.servers_v0.get(currentServerId.value!)
},
enabled: computed(() => {
const enabled = !!currentServerId.value
debug('serverData enabled:', enabled)
return enabled
}),
})
watch(serverData, (val) =>
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
)
watch(serverDataLoading, (val) => debug('serverData loading:', val))
watch(serverDataError, (val) => {
if (val) debug('serverData error:', val)
})
const serverIcon = computed(() => {
if (!currentServerId.value || !import.meta.client) return null
return localStorage.getItem(`server-icon-${currentServerId.value}`)
})
const serverHideInstalled = ref(false)
// TanStack Query for server content list
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
const { data: serverContentData, error: serverContentError } = useQuery({
queryKey: contentQueryKey,
queryFn: () => client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
})
// Watch for errors and notify user
watch(serverContentError, (error) => {
if (error) {
console.error('Failed to load server content:', error)
handleError(error)
}
})
// Re-run search when server content loads so "Hide installed" filter applies
watch(serverContentData, () => {
if (serverHideInstalled.value) {
updateSearchResults(1, false)
}
})
// Install content mutation
const installContentMutation = useMutation({
mutationFn: ({
serverId,
projectId,
versionId,
}: {
serverId: string
projectId: string
versionId: string
}) =>
client.archon.content_v1.addAddon(serverId, currentWorldId.value!, {
project_id: projectId,
version_id: versionId,
}),
onSuccess: () => {
if (currentServerId.value) {
queryClient.refetchQueries({ queryKey: ['content', 'list', currentServerId.value] })
}
},
})
const PERSISTENT_QUERY_PARAMS = ['sid', 'wid', 'shi', 'from']
if (route.query.shi && projectType.value?.id !== 'modpack') {
serverHideInstalled.value = route.query.shi === 'true'
}
const serverFilters = computed(() => {
debug(
'serverFilters recomputing, serverData:',
!!serverData.value,
'projectType:',
projectType.value?.id,
)
const filters = []
if (server.value && projectType.value?.id !== 'modpack') {
const gameVersion = server.value.general?.mc_version
if (serverData.value && projectType.value?.id !== 'modpack') {
const gameVersion = serverData.value.mc_version
if (gameVersion) {
filters.push({
type: 'game_version',
@@ -180,7 +230,7 @@ const serverFilters = computed(() => {
})
}
const platform = server.value.general?.loader?.toLowerCase()
const platform = serverData.value.loader?.toLowerCase()
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
@@ -200,13 +250,20 @@ const serverFilters = computed(() => {
})
}
if (serverHideInstalled.value) {
const installedMods = server.value.content?.data
.filter((x: InstallableMod) => x.project_id)
.map((x: InstallableMod) => x.project_id)
.filter((id): id is string => id !== undefined)
if (projectType.value?.id === 'mod') {
filters.push({
type: 'environment',
option: 'server',
})
}
installedMods
if (serverHideInstalled.value && serverContentData.value) {
const installedIds = (serverContentData.value.addons ?? [])
.filter((x) => x.project_id)
.map((x) => x.project_id)
.filter((id): id is string => id !== null)
installedIds
.map((x: string) => ({
type: 'project_id',
option: `project_id:${x}`,
@@ -215,6 +272,20 @@ const serverFilters = computed(() => {
.forEach((x) => filters.push(x))
}
}
if (currentServerId.value && projectType.value?.id === 'modpack') {
filters.push(
{
type: 'environment',
option: 'client',
},
{
type: 'environment',
option: 'server',
},
)
}
debug('serverFilters result:', filters)
return filters
})
@@ -256,6 +327,7 @@ const {
// Functions
createPageParams,
} = useSearch(projectTypes, tags, serverFilters)
debug('useSearch initialized, requestParams:', requestParams.value)
const selectedFilterTags = computed(() =>
currentFilters.value
@@ -315,42 +387,67 @@ interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject
}
async function serverInstall(project: InstallableSearchResult) {
if (!server.value) {
if (!serverData.value || !currentServerId.value) {
handleError(new Error('No server to install to.'))
return
}
project.installing = true
try {
const versions = (await useBaseFetch(
`project/${project.project_id}/version`,
{},
true,
)) as Labrinth.Versions.v2.Version[]
const version =
versions.find(
(x) =>
x.game_versions.includes(server.value!.general.mc_version) &&
x.loaders.includes(server.value!.general.loader.toLowerCase()),
) ?? versions[0]
if (projectType.value?.id === 'modpack') {
await server.value.general.reinstall(
false,
project.project_id,
version.id,
undefined,
eraseDataOnInstall.value,
)
project.installed = true
navigateTo(`/hosting/manage/${server.value.serverId}/options/loader`)
} else if (projectType.value?.id === 'mod') {
await server.value.content.install('mod', version.project_id, version.id)
await server.value.refresh(['content'])
project.installed = true
} else if (projectType.value?.id === 'plugin') {
await server.value.content.install('plugin', version.project_id, version.id)
await server.value.refresh(['content'])
// TODO: restore limit=1 once the backend fix for version ordering is deployed (limit is applied before sorting)
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const versionId = versions[0]?.id ?? project.latest_version
if (!versionId) {
handleError(new Error('No version found for this modpack'))
project.installing = false
return
}
const modalInstance = onboardingModalRef.value
if (modalInstance) {
onboardingInstallingProject.value = project
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: project.project_id,
versionId,
name: project.title,
iconUrl: project.icon_url ?? undefined,
}
ctx.modal.value?.setStage('final-config')
}
return
} else if (
projectType.value?.id === 'mod' ||
projectType.value?.id === 'plugin' ||
projectType.value?.id === 'datapack'
) {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id)
const isDatapack = projectType.value?.id === 'datapack'
const version = versions.find((x) => {
if (!x.game_versions.includes(serverData.value!.mc_version!)) return false
if (isDatapack) return true
return x.loaders.includes(serverData.value!.loader!.toLowerCase())
})
if (!version) {
handleError(
new Error(
isDatapack
? `No compatible version found for ${serverData.value!.mc_version}`
: `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`,
),
)
project.installing = false
return
}
await installContentMutation.mutateAsync({
serverId: currentServerId.value,
projectId: version.project_id,
versionId: version.id,
})
project.installed = true
}
} catch (e) {
@@ -361,28 +458,6 @@ async function serverInstall(project: InstallableSearchResult) {
}
const noLoad = ref(false)
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage })
const effectiveSortType = computed({
get: () => (currentType.value === 'server' ? serverCurrentSortType.value : currentSortType.value),
set: (v: SortType) => {
if (currentType.value === 'server') serverCurrentSortType.value = v
else currentSortType.value = v
},
})
const effectiveSortTypes = computed(() =>
currentType.value === 'server' ? serverSortTypes : [...sortTypes],
)
const {
data: rawResults,
refresh: refreshSearch,
@@ -390,35 +465,26 @@ const {
} = useLazyFetch(
() => {
const config = useRuntimeConfig()
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (currentType.value === 'server') {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
return `${base}search${serverRequestParams.value}`
}
return `${base}search${requestParams.value}`
const url = `${base}search${requestParams.value}`
debug('useLazyFetch URL:', url)
return url
},
{
watch: false,
transform: (
hits: Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults,
): Labrinth.Search.v2.SearchResults => {
transform: (hits) => {
debug('useLazyFetch transform, hits:', (hits as any)?.total_hits)
noLoad.value = false
if ('hits_per_page' in hits) {
return {
hits: hits.hits as unknown as Labrinth.Search.v2.ResultSearchProject[],
total_hits: hits.total_hits,
limit: hits.hits_per_page,
offset: (hits.page - 1) * hits.hits_per_page,
}
}
return hits
return hits as Labrinth.Search.v2.SearchResults
},
},
)
const results = shallowRef(toRaw(rawResults))
watch(searchLoading, (val) => debug('searchLoading:', val))
watch(rawResults, (val) => debug('rawResults changed, total_hits:', val?.total_hits))
const results = computed(() => rawResults.value)
const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
@@ -428,6 +494,14 @@ function scrollToTop(behavior: ScrollBehavior = 'smooth') {
}
function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
debug(
'updateSearchResults called, page:',
pageNumber,
'query:',
query.value,
'requestParams:',
requestParams.value,
)
currentPage.value = pageNumber
if (resetScroll) {
scrollToTop()
@@ -435,9 +509,11 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
noLoad.value = true
if (query.value === null) {
debug('updateSearchResults: query is null, returning early')
return
}
debug('updateSearchResults: calling refreshSearch')
refreshSearch()
if (import.meta.client) {
@@ -457,7 +533,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
const params = {
...persistentParams,
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
...createPageParams(),
}
router.replace({ path: route.path, query: params })
@@ -468,12 +544,6 @@ watch([currentFilters], () => {
updateSearchResults(1, false)
})
watch([serverCurrentFilters, serverCurrentSortType], () => {
if (currentType.value === 'server') {
updateSearchResults(1, false)
}
})
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
function cycleSearchDisplayMode() {
@@ -507,79 +577,116 @@ const description = computed(
`Search and browse thousands of Minecraft ${projectType.value?.display ?? 'project'}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${projectType.value?.display ?? 'project'}s.`,
)
const serverBackUrl = computed(() => {
if (!serverData.value) return ''
const id = serverData.value.server_id
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
if (fromContext.value === 'reset-server') return `/hosting/manage/${id}/options/loader`
return `/hosting/manage/${id}/content`
})
// Onboarding modpack flow: show creation flow modal overlay on discovery page
const onboardingModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const onboardingInstallingProject = ref<InstallableSearchResult | null>(null)
function onOnboardingHide() {
if (onboardingInstallingProject.value) {
onboardingInstallingProject.value.installing = false
onboardingInstallingProject.value = null
}
}
function onOnboardingBack() {
onboardingModalRef.value?.hide()
}
async function onModpackFlowCreate(config: CreationFlowContextValue) {
if (!currentServerId.value || !config.modpackSelection.value) return
try {
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties: config.buildProperties(),
} satisfies Archon.Content.v1.InstallWorldContent)
if (fromContext.value === 'onboarding') {
await client.archon.servers_v1.endIntro(currentServerId.value)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
} else {
navigateTo(`/hosting/manage/${currentServerId.value}/options/loader`)
}
} catch (e) {
handleError(new Error(`Error installing modpack: ${e}`))
config.loading.value = false
}
}
useSeoMeta({
description,
ogTitle,
ogDescription: description,
})
const serverHits = computed(
() =>
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
)
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
const content = hit.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick:
project_id !== hit.project_id
? () => {
navigateTo(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === hit.project_id,
}
}
return undefined
}
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<Teleport v-if="server" to="#discover-header-prefix">
<Teleport v-if="serverData" to="#discover-header-prefix" defer>
<div
class="mb-4 flex flex-wrap items-center justify-between gap-3 border-0 border-b border-solid border-divider pb-4"
>
<nuxt-link
:to="`/servers/manage/${server.serverId}/content`"
<button
tabindex="-1"
class="flex flex-col gap-4 text-primary"
class="flex cursor-pointer flex-col gap-4 bg-transparent text-primary"
@click="navigateTo(serverBackUrl)"
>
<span class="flex items-center gap-2">
<Avatar
:src="
server.general.is_medal
serverData.is_medal
? 'https://cdn-raw.modrinth.com/medal_icon.webp'
: server.general.image
: (serverIcon ?? MinecraftServerIcon)
"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="bold font-extrabold text-contrast">
{{ server.general.name }}
{{ serverData.name }}
</span>
<span class="flex items-center gap-2 font-semibold text-secondary">
<GameIcon class="h-5 w-5 text-secondary" />
{{ server.general.loader }} {{ server.general.mc_version }}
{{ serverData.loader }} {{ serverData.mc_version }}
</span>
</span>
</span>
</nuxt-link>
</button>
<ButtonStyled>
<nuxt-link :to="`/hosting/manage/${server.serverId}/content`">
<button @click="navigateTo(serverBackUrl)">
<LeftArrowIcon />
Back to server
</nuxt-link>
{{
fromContext === 'onboarding'
? 'Back to setup'
: fromContext === 'reset-server'
? 'Cancel reset'
: 'Back to server'
}}
</button>
</ButtonStyled>
</div>
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">Install content to server</h1>
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{
fromContext === 'reset-server'
? 'Select modpack to install after reset'
: 'Install content to server'
}}
</h1>
</Teleport>
<aside
@@ -588,7 +695,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
}"
aria-label="Filters"
>
<AdPlaceholder v-if="!auth.user && !server" />
<AdPlaceholder v-if="!auth.user && !serverData" />
<div v-if="filtersMenuOpen" class="fixed inset-0 z-40 bg-bg"></div>
<div
class="flex flex-col gap-3"
@@ -615,23 +722,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</ButtonStyled>
</div>
<div
v-if="server && projectType?.id === 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised"
>
<div class="flex flex-row items-center gap-2 px-6 py-4 text-contrast">
<h3 class="m-0 text-lg">Options</h3>
</div>
<div class="flex flex-row items-center justify-between gap-2 px-6">
<label for="erase-data-on-install"> Erase all data on install </label>
<Toggle id="erase-data-on-install" v-model="eraseDataOnInstall" class="flex-none" />
</div>
<div class="px-6 py-4 text-sm">
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the selected modpack.
</div>
</div>
<div
v-if="server && projectType?.id !== 'modpack'"
v-if="serverData && projectType?.id !== 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised p-4"
>
<Checkbox
@@ -641,73 +732,41 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@update:model-value="updateSearchResults()"
/>
</div>
<template v-if="currentType === 'server'">
<SearchSidebarFilter
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="serverCurrentFilters"
v-model:toggled-groups="serverToggledGroups"
:provided-filters="serverFilters"
:filter-type="filterType"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="
![
'server_category_minecraft_server_meta',
'server_category_minecraft_server_community',
'server_game_version',
'server_status',
].includes(filterType.id)
"
>
<template #header>
<h3 class="m-0 text-lg">{{ filterType.formatted_name }}</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</template>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</div>
</aside>
<section class="normal-page__content">
@@ -727,10 +786,10 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="effectiveSortType"
v-model="currentSortType"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="effectiveSortTypes"
:options="[...sortTypes]"
:display-name="(option?: SortType) => option?.display"
@change="updateSearchResults()"
>
@@ -776,14 +835,6 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
/>
</div>
<SearchFilterControl
v-if="currentType === 'server'"
v-model:selected-filters="serverCurrentFilters"
:filters="serverFilterTypes"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="serverFilters"
@@ -791,14 +842,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
:provided-message="messages.providedByServer"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div
v-else-if="
currentType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="no-results"
>
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
</div>
<div v-else class="search-results-container">
@@ -808,37 +852,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
<template v-if="currentType === 'server'">
<template v-for="result in results?.hits" :key="result.project_id">
<ProjectCard
v-for="project in serverHits"
:key="`server-card-${project.project_id}`"
:title="project.name"
:icon-url="project.icon_url || undefined"
:summary="project.summary"
:tags="project.categories"
:link="`/server/${project.slug}`"
:server-online-players="
project.minecraft_java_server?.ping?.data?.players_online ?? 0
"
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
:server-region="project.minecraft_server?.region"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:server-modpack-content="getServerModpackContent(project)"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
:max-tags="2"
is-server-project
exclude-loaders
@mouseenter="handleServerProjectMouseEnter(project)"
@mouseleave="handleProjectHoverEnd"
>
</ProjectCard>
</template>
<template v-else>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
@@ -858,8 +873,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
clientSide: result.client_side,
serverSide: result.server_side,
}
: undefined
"
@@ -869,7 +884,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons || serverData" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
@@ -893,16 +908,16 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</button>
</ButtonStyled>
</template>
<template v-else-if="server">
<template v-else-if="serverData">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
(serverContentData &&
(serverContentData.addons ?? []).find(
(x) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
serverData.upstream?.project_id === result.project_id
"
disabled
>
@@ -933,6 +948,18 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</div>
</div>
</section>
<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>
.normal-page__content {