Skip to content
Merged
20 changes: 17 additions & 3 deletions apps/app-frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
LeftArrowIcon,
LibraryIcon,
NotepadTextIcon,
OrganizationIcon,
RefreshCwIcon,
RightArrowIcon,
SettingsIcon,
Expand Down Expand Up @@ -582,9 +583,15 @@ watch(incompatibilityWarningModal, (modal) => {
}
})

setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
})
setupAuthProvider(
credentials,
async (_redirectPath) => {
await signIn()
},
async () => {
await fetchCredentials()
},
)

async function validateSession(sessionToken) {
try {
Expand Down Expand Up @@ -1280,6 +1287,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
<ChangeSkinIcon />
</NavButton>
<NavButton
v-if="themeStore.featureFlags.your_projects_tab"
v-tooltip.right="'Your projects'"
to="/your-projects"
>
<OrganizationIcon />
</NavButton>
<NavButton
v-tooltip.right="'Library'"
to="/library"
Expand Down
38 changes: 38 additions & 0 deletions apps/app-frontend/src/helpers/mr_auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,44 @@ export async function get(): Promise<ModrinthCredentials | null> {
return await invoke('plugin:mr-auth|get')
}

export type UserProject = {
id: string
slug: string | null
name: string
summary: string
description: string
icon_url: string | null
color: number | null
status: string
project_types: string[]
organization: string | null
downloads: number
followers: number
}

export type UserOrganization = {
id: string
slug: string
name: string
description: string
icon_url: string | null
color: number | null
}

export type UserAllProjects = {
projects: UserProject[]
organizations: Record<string, UserOrganization>
}

/**
* Returns every project the signed-in user can access — their own projects plus
* every project owned by an organization they belong to — including non-public
* statuses (unlisted, private). Resolves to `null` when no user is signed in.
*/
export async function getUserProjects(): Promise<UserAllProjects | null> {
return await invoke('plugin:mr-auth|get_user_projects')
}

export async function cancelLogin(): Promise<void> {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}
179 changes: 136 additions & 43 deletions apps/app-frontend/src/pages/Index.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
<script setup>
import { DownloadIcon, PlayIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectAuth, injectNotificationManager } from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'

import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_search_results, get_version_many } from '@/helpers/cache.js'
import {
get_organization,
get_project,
get_search_results,
get_version_many,
} from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import { list, run, update_managed_modrinth_version } from '@/helpers/instance'
import { injectContentInstall } from '@/providers/content-install'
import { useBreadcrumbs } from '@/store/breadcrumbs'

const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const auth = injectAuth()

const featuredModpacks = ref([])
const featuredMods = ref([])
const filter = ref('')

let featuredLoadId = 0

const route = useRoute()
const breadcrumbs = useBreadcrumbs()

Expand Down Expand Up @@ -77,9 +85,68 @@ const fetchFeaturedProjects = async () => {
}
}

/**
* Hydrates featured packs that the search index can't return.
*
* Search only indexes public (approved/archived) projects, so a featured pack
* that is private or unlisted never appears in the hits. Fetching the project
* directly is authenticated, so it resolves for members who can see the pack
* and fails for everyone else.
*/
const hydrateNonPublicFeatured = async (hits) => {
const found = new Set(hits.map((hit) => hit.project_id))
const missing = featuredProjects.value.filter((p) => !found.has(p.id))

if (missing.length === 0) {
return []
}

// Bypass the cache: whether these resolve depends on the current session,
// so a copy cached while signed in must not leak into a signed-out view.
// A featured pack the current user has no access to is expected to fail
// here, so swallow the error instead of surfacing it as an app error.
const projects = await Promise.all(
missing.map((p) => get_project(p.id, 'bypass').catch(() => null)),
)
Comment thread
cursor[bot] marked this conversation as resolved.

return await Promise.all(
projects
.filter((project) => project?.project_type === 'modpack')
.map(async (project) => {
const organization = project.organization
? await get_organization(project.organization).catch(() => null)
: null

return {
project_id: project.id,
project_type: project.project_type,
slug: project.slug,
title: project.title,
description: project.description,
icon_url: project.icon_url,
author: organization?.name,
// Marks an entry the search index wouldn't return, so it can
// be dropped the moment the session that revealed it ends.
nonPublic: true,
}
}),
)
}

const getFeaturedModpacks = async () => {
// Guards against overlapping loads (mount plus a sign-in) clobbering each
// other, since which packs resolve depends on the session in effect.
const loadId = ++featuredLoadId

await fetchFeaturedProjects()

if (featuredProjects.value.length === 0) {
if (loadId === featuredLoadId) {
featuredModpacks.value = []
}
return
}

// Create structured filters exactly like in the browse file
const filters = []

Expand All @@ -88,11 +155,7 @@ const getFeaturedModpacks = async () => {

// Project ID filter - each project_id is its own entry in the inner array,
// which the Modrinth search API treats as OR (there is no `OR` keyword)
if (featuredProjects.value.length > 0) {
filters.push(featuredProjects.value.map((p) => `project_id:${p.id}`))
} else {
filters.push(['project_id:none'])
}
filters.push(featuredProjects.value.map((p) => `project_id:${p.id}`))

// Build the facets parameter in the format the API expects
const facetsParam = JSON.stringify(filters)
Expand All @@ -101,57 +164,87 @@ const getFeaturedModpacks = async () => {
const query = `?facets=${facetsParam}&limit=10&index=follows${filter.value ? `&query=${filter.value}` : ''}`

const response = await get_search_results(query)
const hits = response?.result?.hits ?? []
const entries = [...hits, ...(await hydrateNonPublicFeatured(hits))]

if (response?.result?.hits) {
const instances = (await list().catch(handleError)) ?? []
if (entries.length === 0) {
if (loadId === featuredLoadId) {
featuredModpacks.value = []
}
return
}

const latestVersions = await Promise.all(
response.result.hits.map(async (hit) => {
try {
const project = await get_project(hit.project_id)
const instances = (await list().catch(handleError)) ?? []

if (!project?.versions?.length) {
return null
}
const latestVersions = await Promise.all(
entries.map(async (entry) => {
try {
const project = await get_project(entry.project_id)

const versions = await get_version_many(project.versions)
return versions.sort((a, b) => new Date(b.date_published) - new Date(a.date_published))[0]
} catch (error) {
handleError(error)
if (!project?.versions?.length) {
return null
}
}),
)

featuredModpacks.value = response.result.hits.map((hit, index) => {
const instance = instances.find((p) => p.link?.project_id === hit.project_id)
const versions = await get_version_many(project.versions)
return versions.sort((a, b) => new Date(b.date_published) - new Date(a.date_published))[0]
} catch (error) {
handleError(error)
return null
}
}),
)

const isInstalled = !!instance
installed.value[hit.project_id] = isInstalled
if (loadId !== featuredLoadId) {
return
}

if (isInstalled && latestVersions[index]) {
const currentVersion = instance.link.version_id
const latestVersion = latestVersions[index].id
hasUpdate.value[hit.project_id] = currentVersion !== latestVersion
}
featuredModpacks.value = entries.map((entry, index) => {
const instance = instances.find((p) => p.link?.project_id === entry.project_id)

return {
...hit,
project_id: hit.project_id,
project_type: hit.project_type,
slug: hit.slug,
latestVersionId: latestVersions[index]?.id,
}
})
const isInstalled = !!instance
installed.value[entry.project_id] = isInstalled

if (!selectedModpackId.value && featuredModpacks.value.length > 0) {
selectedModpackId.value = featuredModpacks.value[0].project_id
if (isInstalled && latestVersions[index]) {
const currentVersion = instance.link.version_id
const latestVersion = latestVersions[index].id
hasUpdate.value[entry.project_id] = currentVersion !== latestVersion
}
} else {
featuredModpacks.value = []

return {
...entry,
project_id: entry.project_id,
project_type: entry.project_type,
slug: entry.slug,
latestVersionId: latestVersions[index]?.id,
}
})

if (!selectedModpackId.value && featuredModpacks.value.length > 0) {
selectedModpackId.value = featuredModpacks.value[0].project_id
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

// Which featured packs resolve depends on the session, so reload when the user
// signs in or out. Without this, a user who signs in while sitting on the home
// screen wouldn't see the private packs they just gained access to.
watch(
() => auth.session_token.value,
() => {
// Any identity change invalidates packs only the previous session could
// see, whether that's a sign-out or a switch straight to another
// account. Drop them synchronously: the reload below is async, so until
// it finishes the list would keep showing them. Public packs stay, so
// the list doesn't blank out.
featuredModpacks.value = featuredModpacks.value.filter((modpack) => !modpack.nonPublic)

if (!featuredModpacks.value.some((m) => m.project_id === selectedModpackId.value)) {
selectedModpackId.value = featuredModpacks.value[0]?.project_id ?? ''
}

getFeaturedModpacks()
},
Comment thread
cursor[bot] marked this conversation as resolved.
)
Comment thread
cursor[bot] marked this conversation as resolved.

watch(selectedModpackId, (newValue) => {
if (newValue) {
localStorage.setItem('lastSelectedModpack', newValue)
Expand Down
Loading
Loading