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
@@ -0,0 +1,379 @@
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, DownloadIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import { defineMessages, IconButton, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useEventListener, useMagicKeys } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
type ProcessEvent = 'installing' | 'launched' | 'finished'
const LOADING_INDICATOR_DELAY_MS = 100
const instanceCardSensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
selectInstance: {
id: 'app.library.instance.select-with-name',
defaultMessage: 'Select {name}',
},
deselectInstance: {
id: 'app.library.instance.deselect-with-name',
defaultMessage: 'Deselect {name}',
},
openInstance: {
id: 'app.library.instance.open-with-name',
defaultMessage: 'Open {name}',
},
loading: { id: 'app.library.instance.loading', defaultMessage: 'Instance is loading...' },
installing: { id: 'app.library.instance.installing', defaultMessage: 'Installing...' },
stop: { id: 'app.library.instance.stop', defaultMessage: 'Stop' },
repair: { id: 'app.library.instance.repair', defaultMessage: 'Repair' },
play: { id: 'app.library.instance.play', defaultMessage: 'Play' },
select: { id: 'app.library.instance.select', defaultMessage: 'Select instance' },
deselect: { id: 'app.library.instance.deselect', defaultMessage: 'Deselect instance' },
})
const {
displayState,
selectedLibraryInstances,
isLibraryInstanceSelectionActive,
activeDraggedInstanceKeys,
} = useLibrary()
const props = defineProps<{
instance: GameInstance
instanceGroupId: string
isSelectionAnchor?: boolean
}>()
const emit = defineEmits<{
(e: 'toggle-selection', shiftKey: boolean): void
}>()
const instanceCardElement = ref<InstanceType<typeof InstanceCardView> | null>(null)
const playing = ref(false)
const loading = ref(false)
const currentEvent = ref<ProcessEvent | null>(null)
const isPrimaryPointerDown = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const loadingIndicatorRequested = computed(
() => !playing.value && (modLoading.value || installing.value),
)
const loadingIndicatorVisible = ref(false)
watch(
loadingIndicatorRequested,
(requested, _, onCleanup) => {
loadingIndicatorVisible.value = false
if (!requested) return
const timeout = setTimeout(() => {
loadingIndicatorVisible.value = true
}, LOADING_INDICATOR_DELAY_MS)
onCleanup(() => clearTimeout(timeout))
},
{ immediate: true },
)
const selectionKey = computed(() =>
getLibraryInstanceSelectionKey({
instanceId: props.instance.id,
groupId: props.instanceGroupId,
}),
)
const selected = computed(() => selectedLibraryInstances.value.has(selectionKey.value))
const keys = useMagicKeys()
const holdingShift = computed(() => keys.shift.value)
const isPartOfActiveDrag = computed(() => activeDraggedInstanceKeys.value.has(selectionKey.value))
const { isDragging } = useDraggable({
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
element: instanceCardElement,
disabled: computed(() => displayState.value.group !== 'Group'),
sensors: instanceCardSensors,
data: computed(() => ({
instanceId: props.instance.id,
fromGroup: props.instanceGroupId,
})),
})
const router = useRouter()
const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
}
const toggleSelection = (event?: MouseEvent) => {
emit('toggle-selection', event?.shiftKey ?? false)
}
const handlePointerDown = (event: PointerEvent) => {
isPrimaryPointerDown.value =
event.button === 0 &&
!(event.target instanceof Element && event.target.closest('.selection-button'))
}
const resetPrimaryPointer = () => {
isPrimaryPointerDown.value = false
}
useEventListener(window, 'pointerup', resetPrimaryPointer)
useEventListener(window, 'pointercancel', resetPrimaryPointer)
useEventListener(window, 'blur', resetPrimaryPointer)
const activateCard = (event: MouseEvent) => {
if (isLibraryInstanceSelectionActive.value || event.shiftKey) {
toggleSelection(event)
} else {
void seeInstance()
}
}
const handleCardKeydown = (event: KeyboardEvent) => {
if (event.target !== event.currentTarget) return
if (event.key === 'Enter') {
event.preventDefault()
if (isLibraryInstanceSelectionActive.value) {
toggleSelection()
} else {
void seeInstance()
}
} else if (event.key === ' ' && isLibraryInstanceSelectionActive.value) {
event.preventDefault()
toggleSelection()
}
}
const checkProcess = async () => {
const runningProcesses = (await get_by_instance_id(props.instance.id).catch(handleError)) ?? []
playing.value = runningProcesses.length > 0
}
const play = async (event: MouseEvent | null, context: string) => {
event?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
})
loading.value = false
}
const stop = async (event: MouseEvent | null, context: string) => {
event?.stopPropagation()
playing.value = false
await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
}
const repair = async (event: MouseEvent) => {
event.stopPropagation()
if (props.instance.quarantined) return
if (
props.instance.install_stage !== 'pack_installed' &&
(props.instance.link?.type === 'modrinth_modpack' ||
props.instance.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(props.instance.id, {
type: 'fromVersionId',
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
title: props.instance.name,
}).catch(handleError)
} else {
await install_existing_instance(props.instance.id, false).catch(handleError)
}
}
const openFolder = async () => {
await showInstanceInFolder(props.instance.id)
}
const addContent = async () => {
if (props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.id },
})
}
defineExpose({
play,
stop,
seeInstance,
openFolder,
addContent,
instance: props.instance,
})
useAppEvent('process', (event) => {
if (event.instance_id === props.instance.id) {
currentEvent.value = event.event
playing.value = event.event === 'launched'
}
})
onMounted(() => {
void checkProcess()
})
</script>
<template>
<InstanceCardView
ref="instanceCardElement"
class="group/card cursor-pointer -outline-offset-2 focus-visible:!outline-2 hover:brightness-110"
:class="{
'!scale-100': isDragging,
'opacity-50': isPartOfActiveDrag,
'scale-[0.95]': isPrimaryPointerDown && !isLibraryInstanceSelectionActive,
}"
:instance="instance"
:selected="selected"
data-library-instance-card
:data-instance-id="instance.id"
:data-instance-group="instanceGroupId"
role="button"
tabindex="0"
:aria-label="
isLibraryInstanceSelectionActive
? formatMessage(selected ? messages.deselectInstance : messages.selectInstance, {
name: instance.name,
})
: formatMessage(messages.openInstance, { name: instance.name })
"
:aria-pressed="isLibraryInstanceSelectionActive ? selected : undefined"
@click="activateCard"
@keydown="handleCardKeydown"
@mouseenter="checkProcess"
@pointerdown="handlePointerDown"
>
<template #loading>
<div
v-if="loadingIndicatorVisible"
class="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center"
>
<div class="absolute inset-0 bg-surface-1 opacity-30" />
<SpinnerIcon
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
class="relative size-[30%] animate-spin text-contrast"
tabindex="-1"
/>
</div>
</template>
<template #leading>
<div class="relative flex size-12 shrink-0 items-center justify-center">
<div class="absolute inset-0 flex items-center justify-center">
<IconButton
v-if="playing"
v-tooltip="formatMessage(messages.stop)"
:label="formatMessage(messages.stop)"
type="colored"
color="red"
size="lg"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<StopCircleIcon />
</IconButton>
<IconButton
v-else-if="
!modLoading &&
!installing &&
!isLibraryInstanceSelectionActive &&
!installed &&
!instance.quarantined
"
v-tooltip="formatMessage(messages.repair)"
:label="formatMessage(messages.repair)"
type="colored"
color="brand"
size="lg"
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
@click="(e) => repair(e)"
>
<DownloadIcon />
</IconButton>
<IconButton
v-else-if="
!modLoading &&
!installing &&
!isLibraryInstanceSelectionActive &&
!instance.quarantined
"
v-tooltip="formatMessage(messages.play)"
:label="formatMessage(messages.play)"
type="colored"
color="brand"
size="lg"
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<PlayIcon class="translate-x-px" />
</IconButton>
</div>
</div>
</template>
<template #overlay>
<button
type="button"
class="selection-button group/selection absolute right-2 top-1.5 z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
:aria-label="formatMessage(selected ? messages.deselect : messages.select)"
:aria-pressed="selected"
@click.stop="toggleSelection"
>
<span
v-tooltip="formatMessage(selected ? messages.deselect : messages.select)"
class="relative flex size-[24px] items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
:class="{
'border-0 !opacity-100': selected,
'border-2 border-solid border-primary bg-transparent': !selected,
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
holdingShift && isSelectionAnchor,
}"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-contrast" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
</template>
</InstanceCardView>
</template>