feat: app onboarding and library (#6923)

* refactor: remove unused legacy onboarded flag

* sqlx prepare

* feat: add onboarding checklist app db and add vue provider

* feat: add welcome page

* feat: add onboarding checklist component

* feat: move checklist visibility source of truth in app db

* feat: split up import modpack button

* feat-small: style tweak

* feat: remove discover section from homepage and add library

* refactor: move welcome screen to component

* refactor: move grid display into /library

* refactor: rename existing components

* refactor: split up the current library page files

* feat: add create group modal

* feat: add instance group definitions

* feat: update accordian for instance groups

* feat: add delete group

* feat: implement rename instance groups

* refactor: move instance group api out

* feat: add context menu for groups

* feat: style instance items in library

* feat: add selected instance item state

* feat: implement action bar for selection

* feat: drag and drop to move between groups

* feat: show ungrouped with header

* feat: improve edit group name

* small fix

* small style fixes

* feat: improve selection behaviour

* fix: dont toggle group when context menu open

* feat: drag and drop multiple selected

* small

* feat: add remove from group in action bar

* feat: add delay on icon hiding in inline input

* feat: improve icon hiding delay

* fix: bad routes.js

* feat: implement sort and filter options

* refactor: dont prefix filenames

* feat: change open/collapse group target

* feat: improve ungrouped behaviour

* feat: add shift to select multiple instances

* fix: icon disappear transition

* feat: add esc while dragging to cancel

* feat: update sorting and moved label

* feat: update context menu

* fix: multiple context menus can be opened

* refactor: instance groups to be identified by ID instead of name, names become display data

* feat: creating new group always adds instance

* feat: create group button, fix instance selection, and increase group name max length

* feat: strokes

* fix: show getting started checklist in all sidebar

* feat: set size on microsoft login screen

* fix: use ButtonStyled in Chips component

* feat: creating groups with selected instances and fix some moving selection and duplication bugs

* pnpm prepr

* feat: add tooltip for instance already in group

* feat: create instance with content in creation flow modal

- search modpack becomes search projects
- creation flow modal context receives prepareProjectInstall and createProjectInstall callbacks, from content-install.ts (shared with ContentInstallModal)

* feat: update instance card styles

* refactor: separate out instance card content

* update styles

* refactor: instance card

* fix: width of instance

* feat: update empty state

* feat: update sort and grouping dropdowns

* feat: better context menu options

* feat: add group action buttons

* feat: add/remove favourites group

* remove manual sort

* feat: improve the "New group" jank

* feat: add group by instance type and change group sorting

* feat: welcome screen styles match figma

* feat: not signed in skins demo

* feat: new hosting empty state

* feat: new instance card style

* feat: add tooltip and spice up animation more

* feat: improve the transitions for groups after dropping many instances

* feat: set instance group membership for bulk moving

* feat: remove fade out

* fix: allow dragging from buttons inside instance card

* fix: library page load in behaviour

* small fix

* feat: add group ordering and reordering

* feat: add drag to reorder groups

* fix: switching page not clearing selected instances

* style fixes

* style fix

* fix spacing

* feat: jump in changes

* feat: add styled newly created instances

* feat: improve jump in cards styles

* fix: welcome screen style

* fix: empty state styles

* feat: max 5 jump in items

* feat: max new instances to 3

* fix: search icon

* feat: clear selection after move

* feat: skins list use check icon styles

* feat: list will be deleted instances and default sort last played

* fix: stroke

* remove: no more group settings in instance general settings

* fix: resolving modpack server install

* feat: icon editor

* feat: add three hue shifted variants

* fix: surprise me doesnt pick symbol

* feat: add customize icon to create instance modal

* feat: skins loading state style

* fix: welcome logo

* fix: copy

* fix: left over usage of ButtonStyled from main's refactor

* feat: some leaked merge stuff

* feat: server empty state styles

* remove: instance helper text

* remove: auto link in set up stage

* feat: auto focus on search when modal open

* fix: delete instance modal style

* remove: modpack meta repeating

* fix: icon

* fix: font weight

* fix: dropdown filter preview heights

* pnpm prepr

* fix: sign into modrinth flashing change in position

* feat: add avatar transparent corner gets padding

* feat: drag and hold over group opens group

* feat: add random icon for custom instance

* feat: update loading icon

* polish: empty state for search

* polish: jump in cards

* fix: avatar could skip checking for image if tauri loads it first

* feat: add icon customize and randomizer in content install modal for add to new instances

* fix: avatar cannot read image pixels due to cors

* refactor: popup notifications toast type for instance sharing notifications

* feat: apply random icons modal and add popup notification for it

* polish: style

* fix: set_webview_visible

* QA

* qa

* feat: add inner white 15 opacity stroke around avatar

* add to catalog

* pnpm prepr

* fix: app event import

* feat: add new icons and backgrounds for icon editor

* feat: recents list generate from randomized icons and save icon in editor + icon editor modal fixes

* qa

* refactor: recipe -> config

* fix: event bus

* polish

* feat: add safe guard against randomizing empty icons

* feat: navigate to instance after creation

* fix: icon file names

* i18n on welcome screen

* i18n pass on PR

* fix: inconsistent max group name length

* refactor: use sqlx macro

* feat: add instance icon background selection drag

* feat: update catalog order

* fix: remove gradient for welcome screen

* remove: frog

* fix: min width

* feat: update corner size

* fix: context menu styles

* prepr

* fix: instance name trimming

* reorder

* fix: instance card loading state stayed until hovering

* feat: add icon randomizer blacklist

* blog

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Truman Gao
2026-08-18 01:08:09 +00:00
committed by GitHub
co-authored by Prospector
parent d5556271f9
commit e79e0d024b
347 changed files with 12456 additions and 4505 deletions
@@ -6,7 +6,6 @@ import {
getLatestMatchingInstallVersion,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { nextTick, type Ref, ref } from 'vue'
@@ -30,6 +29,7 @@ import {
get,
get_install_candidates,
get_projects,
getInstanceIconUrl,
install_project_with_dependencies,
list,
remove_project,
@@ -145,6 +145,7 @@ export interface ContentInstallContext {
loader: string
gameVersion: string
}) => Promise<void>
prepareNewInstance: (projectId: string) => Promise<void>
handleNavigate: (instance: ContentInstallInstance) => void
handleCancel: () => void
setContentInstallModal: (ref: ModalRef) => void
@@ -424,7 +425,12 @@ export function createContentInstall(opts: {
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
onInstall: ContentInstallCallback,
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
hints?: {
preferredLoader?: string
preferredGameVersion?: string
showProjectInfo?: boolean
showModal?: boolean
},
) {
currentProject = project
currentVersions = versions
@@ -434,6 +440,7 @@ export function createContentInstall(opts: {
loading.value = true
defaultTab.value = 'existing'
let projectInfoPromise: Promise<unknown> = Promise.resolve()
if (hints?.showProjectInfo) {
projectInfo.value = {
title: project.title,
@@ -441,7 +448,7 @@ export function createContentInstall(opts: {
link: `/project/${project.slug ?? project.id}`,
}
if (project.organization) {
get_organization(project.organization)
projectInfoPromise = get_organization(project.organization)
.then((org: { id: string; slug: string; name: string; icon_url?: string }) => {
if (projectInfo.value) {
const orgSlug = org.slug ?? org.id
@@ -458,7 +465,7 @@ export function createContentInstall(opts: {
})
.catch(() => {})
} else if (project.team) {
get_team(project.team)
projectInfoPromise = get_team(project.team)
.then(
(
members: {
@@ -509,10 +516,12 @@ export function createContentInstall(opts: {
: null
await nextTick()
modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
if (hints?.showModal !== false) {
modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
}
get_game_versions()
const gameVersionMetadataPromise = get_game_versions()
.then((allGameVersions) => {
const releases = new Set<string>()
const ordered: string[] = []
@@ -541,7 +550,7 @@ export function createContentInstall(opts: {
return {
id: instance.id,
name: instance.name,
iconUrl: instance.icon_path ? convertFileSrc(instance.icon_path) : null,
iconUrl: getInstanceIconUrl(instance.icon_path),
installed: instance.installed,
compatible: instance.compatible,
installing: false,
@@ -559,6 +568,24 @@ export function createContentInstall(opts: {
} finally {
loading.value = false
}
await gameVersionMetadataPromise
await projectInfoPromise
}
async function prepareNewInstance(projectId: string) {
const project: Labrinth.Projects.v2.Project = await get_project(projectId, 'must_revalidate')
if (!project || project.project_type === 'modpack') {
throw new Error(`Project cannot be prepared as a new instance: '${projectId}'`)
}
const versions = (
(await get_version_many(project.versions)) as Labrinth.Versions.v2.Version[]
).sort((a, b) => dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf())
await showModInstallModal(project, versions, () => {}, {
showProjectInfo: true,
showModal: false,
})
}
function getInstallTargets(versions: Labrinth.Versions.v2.Version[]) {
@@ -940,6 +967,7 @@ export function createContentInstall(opts: {
projectInfo,
handleInstallToInstance,
handleCreateAndInstall,
prepareNewInstance,
handleNavigate,
handleCancel,
setContentInstallModal(ref: ModalRef) {
@@ -0,0 +1,57 @@
import { createContext } from '@modrinth/ui'
import { computed, type ComputedRef, ref } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { getOnboardingChecklist, type OnboardingChecklist } from '@/helpers/onboarding-checklist'
import type { AppEvents } from '@/providers/app-events'
export interface OnboardingChecklistContext {
hasCreatedInstance: ComputedRef<boolean>
hasLoggedIntoMinecraft: ComputedRef<boolean>
hasLoggedIntoModrinth: ComputedRef<boolean>
isReady: ComputedRef<boolean>
showChecklist: ComputedRef<boolean>
}
export interface OnboardingChecklistProvider extends OnboardingChecklistContext {
initialize: () => Promise<void>
}
export const [injectOnboardingChecklist, provideOnboardingChecklist] =
createContext<OnboardingChecklistContext>('root', 'onboardingChecklist')
export function setupOnboardingChecklistProvider(
appEvents: AppEvents,
): OnboardingChecklistProvider {
const checklist = ref<OnboardingChecklist>()
const context: OnboardingChecklistContext = {
hasCreatedInstance: computed(() => checklist.value?.has_created_instance ?? false),
hasLoggedIntoMinecraft: computed(() => checklist.value?.has_logged_into_minecraft ?? false),
hasLoggedIntoModrinth: computed(() => checklist.value?.has_logged_into_modrinth ?? false),
isReady: computed(() => checklist.value !== undefined),
showChecklist: computed(() => checklist.value?.show_checklist ?? false),
}
provideOnboardingChecklist(context)
const applyChecklist = (nextChecklist: OnboardingChecklist) => {
checklist.value = {
has_created_instance:
(checklist.value?.has_created_instance ?? false) || nextChecklist.has_created_instance,
has_logged_into_minecraft:
(checklist.value?.has_logged_into_minecraft ?? false) ||
nextChecklist.has_logged_into_minecraft,
has_logged_into_modrinth:
(checklist.value?.has_logged_into_modrinth ?? false) ||
nextChecklist.has_logged_into_modrinth,
show_checklist: (checklist.value?.show_checklist ?? true) && nextChecklist.show_checklist,
}
}
useAppEvent('onboarding_checklist', applyChecklist, appEvents)
const initialize = async () => applyChecklist(await getOnboardingChecklist())
return { ...context, initialize }
}
@@ -152,6 +152,7 @@ export function createServerInstall(opts: {
function showModpackInstallSuccess(project: GameInstance, serverAddress: string | null) {
opts.popupNotificationManager.addPopupNotification({
contentType: 'standard',
title: 'Install complete',
text: `${project.name} is installed and ready to play.`,
type: 'success',
@@ -187,6 +188,7 @@ export function createServerInstall(opts: {
function showUpdateSuccess(instance: GameInstance, serverAddress: string | null) {
opts.popupNotificationManager.addPopupNotification({
contentType: 'standard',
title: 'Update complete',
text: `${instance.name} has been updated and is ready to play.`,
type: 'success',
+9 -1
View File
@@ -1,6 +1,10 @@
import type { AbstractModrinthClient } from '@modrinth/api-client'
import type { AbstractPopupNotificationManager, AbstractWebNotificationManager } from '@modrinth/ui'
import type { InstanceIconConfig } from '@/helpers/types'
import type { AppEvents } from './app-events'
import { setupOnboardingChecklistProvider } from './onboarding-checklist'
import { setupCreationModal } from './setup/creation-modal'
import { setupFileDropProvider } from './setup/file-drop'
import { setupFilePickerProvider } from './setup/file-picker'
@@ -12,14 +16,18 @@ export function setupProviders(
client: AbstractModrinthClient,
notificationManager: AbstractWebNotificationManager,
_popupNotificationManager: AbstractPopupNotificationManager,
appEvents: AppEvents,
getGeneratedIconConfig?: (iconPath: string) => InstanceIconConfig | null,
) {
setupUserCountryProvider(client)
setupTagsProvider(notificationManager)
setupFileDropProvider()
setupFilePickerProvider()
setupInstanceImportProvider(notificationManager)
const onboardingChecklist = setupOnboardingChecklistProvider(appEvents)
return {
...setupCreationModal(notificationManager),
...setupCreationModal(notificationManager, getGeneratedIconConfig),
onboardingChecklist,
}
}
@@ -220,6 +220,10 @@ export function decodeAppEvent(payload: ArrayBuffer): AppEvent {
return { type: event.tag, payload: normalizeProcessPayload(event.value) }
case 'instance':
return { type: event.tag, payload: normalizeInstancePayload(event.value) }
case 'instance_groups_changed':
return { type: event.tag, payload: wireObject(event.value) }
case 'onboarding_checklist':
return { type: event.tag, payload: wireObject(event.value) }
case 'instance_bulk_update_progress':
return { type: event.tag, payload: normalizeBulkUpdatePayload(event.value) }
case 'install_job':
@@ -10,20 +10,24 @@ import { useRouter } from 'vue-router'
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { get_search_results } from '@/helpers/cache.js'
import { import_instance } from '@/helpers/import.js'
import {
type CreatePackLocation,
install_create_instance,
install_create_modpack_instance,
install_get_modpack_preview,
installJobInstanceId,
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata.js'
import type { InstanceLoader } from '@/helpers/types'
import type { InstanceIconConfig, InstanceLoader } from '@/helpers/types'
import { useTheming } from '@/store/state'
export function setupCreationModal(notificationManager: AbstractWebNotificationManager) {
export function setupCreationModal(
notificationManager: AbstractWebNotificationManager,
getGeneratedIconConfig?: (iconPath: string) => InstanceIconConfig | null,
) {
const { handleError } = notificationManager
const router = useRouter()
const themeStore = useTheming()
@@ -48,6 +52,19 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
provide('showCreationModal', () => {
installationModal.value?.show()
})
provide('showImportModal', async () => {
await installationModal.value?.show()
installationModal.value?.ctx.setImportMode()
})
async function navigateToCreatedInstance(
job: Awaited<ReturnType<typeof install_create_instance>>,
) {
const instanceId = installJobInstanceId(job)
if (instanceId) {
await router.push(`/instance/${encodeURIComponent(instanceId)}`)
}
}
async function proceedWithModpackCreation(
projectId: string,
@@ -55,16 +72,23 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
name: string,
iconUrl?: string,
) {
await install_create_modpack_instance({
const job = await install_create_modpack_instance({
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title: name,
icon_url: iconUrl,
}).catch(handleError)
})
await navigateToCreatedInstance(job)
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
}
async function proceedWithModpackFileCreation(location: CreatePackLocation) {
const job = await install_create_modpack_instance(location)
await navigateToCreatedInstance(job)
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
}
async function handleCreate(config: CreationFlowContextValue) {
try {
if (config.modpackSelection.value) {
@@ -84,16 +108,25 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
installationModal.value?.hide()
if (config.isImportMode.value) {
let importCount = 0
let importedInstanceId: string | null = null
for (const [launcherName, instanceSet] of Object.entries(
config.importSelectedInstances.value,
)) {
const launcher = config.importLaunchers.value.find((l) => l.name === launcherName)
if (!launcher || instanceSet.size === 0) continue
for (const name of instanceSet) {
await import_instance(launcher.name, launcher.path, name).catch(handleError)
importCount += 1
const job = await import_instance(launcher.name, launcher.path, name).catch(handleError)
if (job) {
importedInstanceId = installJobInstanceId(job)
}
}
}
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
if (importCount === 1 && importedInstanceId) {
await router.push(`/instance/${encodeURIComponent(importedInstanceId)}`)
}
return
}
@@ -117,17 +150,16 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
: config.modpackFilePath.value
if (unknownPackWarningModal.value) {
unknownPackWarningModal.value?.show(
() => install_create_modpack_instance(location).then(() => undefined),
() => proceedWithModpackFileCreation(location).catch(handleError),
fileName,
preview.externalFilesInModpack,
)
} else {
await install_create_modpack_instance(location)
await proceedWithModpackFileCreation(location)
}
} else {
await install_create_modpack_instance(location)
await proceedWithModpackFileCreation(location)
}
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
return
}
@@ -141,13 +173,15 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
const iconPath = config.instanceIconPath.value ?? null
const name = config.instanceName.value.trim() || config.autoInstanceName.value
await install_create_instance({
const job = await install_create_instance({
name,
gameVersion: config.selectedGameVersion.value!,
loader: loader as InstanceLoader,
loaderVersion,
iconPath,
}).catch(handleError)
iconConfig: iconPath ? getGeneratedIconConfig?.(iconPath) : null,
})
await navigateToCreatedInstance(job)
trackEvent('InstanceCreate', {
source: 'CreationModal',
@@ -168,7 +202,11 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
if (!pendingModpackCreation.value) return
const { projectId, versionId, name, iconUrl } = pendingModpackCreation.value
pendingModpackCreation.value = null
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
try {
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
} catch (error) {
handleError(error as Error)
}
}
function handleModpackDuplicateGoToInstance(instanceId: string) {
@@ -181,8 +219,10 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
router.push('/browse/modpack')
}
async function searchModpacks(query: string, limit: number = 10) {
const params = [`facets=[["project_type:modpack"]]`, `limit=${limit}`]
async function searchProjects(query: string, limit: number = 10) {
const projectTypes = ['mod', 'modpack', 'resourcepack', 'shader', 'datapack']
const facets = JSON.stringify([projectTypes.map((type) => `project_type:${type}`)])
const params = [`facets=${encodeURIComponent(facets)}`, `limit=${limit}`]
if (query) {
params.push(`query=${encodeURIComponent(query)}`)
}
@@ -191,19 +231,13 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
return { hits: [], offset: 0, limit, total_hits: 0 }
}
async function getProjectVersions(projectId: string) {
const versions = await get_project_versions(projectId)
return versions ?? []
}
return {
installationModal,
unknownPackWarningModal,
fetchExistingInstanceNames,
handleCreate,
handleBrowseModpacks,
searchModpacks,
getProjectVersions,
searchProjects,
getLoaderManifest,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
@@ -10,7 +10,7 @@ import {
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
type ModpackSearchResult,
type ProjectSearchResult,
readStoredServerInstallQueue,
resolveServerAddonInstallPlans,
useServerContextRuntime,
@@ -69,7 +69,7 @@ export interface ServerInstallContentContext {
installQueuedServerInstallsAndBack: () => Promise<boolean>
initServerContext: () => Promise<void>
watchServerContextChanges: () => void
searchServerModpacks: (query: string, limit?: number) => Promise<ModpackSearchResult>
searchServerModpacks: (query: string, limit?: number) => Promise<ProjectSearchResult>
getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]>
enforceSetupModpackRoute: (currentProjectType: string | undefined) => void
getQueuedServerInstallPlans: () => Map<string, BrowseInstallPlan<InstallableSearchResult>>