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,233 @@
<script setup lang="ts">
import { BanIcon, SpinnerIcon, TagCategoryWandSparklesIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
defineMessages,
IconButton,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { nextTick, ref, useTemplateRef } from 'vue'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import { toError } from '@/helpers/errors'
import { list as listInstances } from '@/helpers/instance'
import icon01 from './assets/01.png'
import icon02 from './assets/02.png'
import icon03 from './assets/03.png'
import icon04 from './assets/04.png'
import icon05 from './assets/05.png'
import icon06 from './assets/06.png'
import icon07 from './assets/07.png'
import icon08 from './assets/08.png'
import icon09 from './assets/09.png'
const emit = defineEmits<{
close: []
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const iconEditorModal = useTemplateRef<InstanceType<typeof IconEditorModal>>('iconEditorModal')
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
const iconlessInstanceIds = ref<string[]>([])
const loading = ref(false)
const applying = ref(false)
const icons = [icon01, icon02, icon03, icon04, icon05, icon06, icon07, icon08, icon09]
const messages = defineMessages({
badge: {
id: 'app.icon-editor.apply-icons-modal.badge',
defaultMessage: 'New this update',
},
title: {
id: 'app.icon-editor.apply-icons-modal.title',
defaultMessage: 'Custom icon editor',
},
description: {
id: 'app.icon-editor.apply-icons-modal.description',
defaultMessage:
'Create custom icons for your instances right in the Modrinth App. Mix and match backgrounds with symbols from Minecraft and popular mods!',
},
instancesWithoutIcons: {
id: 'app.icon-editor.apply-icons-modal.instances-without-icons',
defaultMessage:
'{count, plural, one {# instance doesnt} other {# instances dont}} have icons yet. Want us to give {count, plural, one {it} other {them}} random ones to get started?',
},
skip: {
id: 'app.icon-editor.apply-icons-modal.skip',
defaultMessage: 'Skip',
},
randomIcons: {
id: 'app.icon-editor.apply-icons-modal.random-icons',
defaultMessage: 'Random icons',
},
close: {
id: 'app.icon-editor.apply-icons-modal.close',
defaultMessage: 'Close',
},
applyError: {
id: 'app.icon-editor.apply-icons-modal.apply-error',
defaultMessage: 'Failed to apply random icons to instances.',
},
applySuccess: {
id: 'app.icon-editor.apply-icons-modal.apply-success',
defaultMessage: 'Randomized icon for {num, plural, one {# instance} other {# instances}}.',
},
})
async function show() {
modal.value?.show()
loading.value = true
try {
iconlessInstanceIds.value = (await listInstances())
.filter((instance) => !instance.icon_path)
.map((instance) => instance.id)
} catch (error) {
handleError(toError(error))
iconlessInstanceIds.value = []
} finally {
loading.value = false
}
}
function hide() {
modal.value?.hide()
}
function handleHide() {
emit('close')
}
async function applyIcons() {
if (applying.value || loading.value) return
applying.value = true
const instanceCount = iconlessInstanceIds.value.length
let applied = false
try {
const results = await Promise.all(
iconlessInstanceIds.value.map(async (instanceId) => {
const generated = await iconEditorModal.value?.randomize()
if (!generated) return false
return (
(await iconEditorModal.value?.applyGeneratedIcon(instanceId, generated.config)) ?? false
)
}),
)
applied = results.every(Boolean)
} finally {
applying.value = false
}
if (applied) {
await nextTick()
hide()
addNotification({
type: 'success',
title: formatMessage(messages.applySuccess, { num: instanceCount }),
})
} else {
addNotification({
type: 'error',
title: formatMessage(messages.applyError),
})
}
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
hide-header
no-padding
width="770px"
max-width="calc(100vw - 2rem)"
:aria-label="formatMessage(messages.title)"
:on-after-hide="handleHide"
:disable-close="applying"
class="!overflow-hidden !rounded-[20px]"
>
<div class="grid h-[384px] w-[768px] max-w-full grid-cols-2">
<section class="flex min-w-0 flex-col gap-6 bg-surface-3 p-8">
<div
class="flex h-8 w-fit items-center rounded-full border border-solid border-brand bg-brand-highlight px-2.5 text-sm font-base leading-5 text-brand"
>
{{ formatMessage(messages.badge) }}
</div>
<div class="flex min-w-0 flex-col gap-4">
<h2 class="m-0 text-2xl font-semibold leading-6 text-contrast">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 leading-6 text-primary">
{{ formatMessage(messages.description) }}
</p>
<p v-if="iconlessInstanceIds.length" class="m-0 leading-6 text-primary">
{{
formatMessage(messages.instancesWithoutIcons, {
count: iconlessInstanceIds.length,
})
}}
</p>
</div>
<div class="flex mt-auto items-center gap-2.5">
<Button size="lg" :disabled="applying" @click="hide">
<template v-if="iconlessInstanceIds.length === 0">
{{ formatMessage(messages.close) }}
</template>
<template v-else>
<BanIcon />
{{ formatMessage(messages.skip) }}
</template>
</Button>
<Button
v-if="iconlessInstanceIds.length !== 0"
type="colored"
color="brand"
size="lg"
:disabled="loading || applying"
@click="applyIcons"
>
<SpinnerIcon v-if="applying" class="animate-spin" />
<TagCategoryWandSparklesIcon v-else />
{{ formatMessage(messages.randomIcons) }}
</Button>
</div>
</section>
<section
class="relative flex min-w-0 items-center justify-center border-0 border-l border-solid border-surface-5 bg-surface-2 p-10"
>
<IconButton
type="quiet"
size="sm"
:label="formatMessage(messages.close)"
class="!absolute right-6 top-6 z-10"
:disabled="applying"
@click="hide"
>
<XIcon />
</IconButton>
<div class="grid size-60 grid-cols-3 grid-rows-3 gap-4" aria-hidden="true">
<Avatar
v-for="(icon, index) in icons"
:key="index"
:src="icon"
size="100%"
class="min-h-0 min-w-0 !rounded-2xl"
/>
</div>
</section>
</div>
</NewModal>
<IconEditorModal ref="iconEditorModal" />
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { Button, defineMessages, IconButton, useVIntl } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import ApplyNewIconsModal from './apply-new-icons-modal.vue'
import icon01 from './assets/01.png'
import icon02 from './assets/02.png'
import icon03 from './assets/03.png'
import icon04 from './assets/04.png'
import icon05 from './assets/05.png'
import icon06 from './assets/06.png'
import icon07 from './assets/07.png'
import icon08 from './assets/08.png'
import icon09 from './assets/09.png'
const emit = defineEmits<{
dismiss: []
}>()
const { formatMessage } = useVIntl()
const applyNewIconsModal =
useTemplateRef<InstanceType<typeof ApplyNewIconsModal>>('applyNewIconsModal')
const modalOpen = ref(false)
const icons = [icon01, icon02, icon03, icon04, icon05, icon06, icon07, icon08, icon09]
const messages = defineMessages({
title: {
id: 'app.icon-editor.notification.title',
defaultMessage: 'New icon editor',
},
description: {
id: 'app.icon-editor.notification.description',
defaultMessage: 'Personalize your instances!',
},
viewUpdate: {
id: 'app.icon-editor.notification.view-update',
defaultMessage: 'View update',
},
dismiss: {
id: 'app.icon-editor.notification.dismiss',
defaultMessage: 'Dismiss',
},
close: {
id: 'app.icon-editor.notification.close',
defaultMessage: 'Close',
},
})
function viewUpdate() {
modalOpen.value = true
applyNewIconsModal.value?.show()
}
function handleModalClose() {
modalOpen.value = false
emit('dismiss')
}
</script>
<template>
<section
v-show="!modalOpen"
class="relative ml-auto w-full max-w-full overflow-hidden rounded-2xl border border-solid border-surface-5 bg-surface-3 p-4 shadow-xl"
:aria-label="formatMessage(messages.title)"
>
<div class="icon-grid" aria-hidden="true">
<div v-for="(icon, index) in icons" :key="index" class="icon-tile">
<img :src="icon" alt="" />
</div>
</div>
<div class="icon-fade" />
<div class="relative z-[1] flex w-full flex-col items-start gap-3">
<div class="flex w-full flex-col gap-2">
<div class="flex w-full items-center justify-between gap-2.5">
<div class="min-w-0 flex-1 font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</div>
<IconButton
class="-m-1.5 -mx-2"
type="quiet"
size="sm"
:label="formatMessage(messages.close)"
@click="emit('dismiss')"
>
<XIcon />
</IconButton>
</div>
<div class="text-primary">
{{ formatMessage(messages.description) }}
</div>
</div>
<div class="flex items-center gap-1.5">
<Button type="colored" color="brand" @click="viewUpdate">
{{ formatMessage(messages.viewUpdate) }}
</Button>
<Button @click="emit('dismiss')">
{{ formatMessage(messages.dismiss) }}
</Button>
</div>
</div>
</section>
<ApplyNewIconsModal ref="applyNewIconsModal" @close="handleModalClose" />
</template>
<style scoped>
.icon-grid {
position: absolute;
top: -4px;
left: 232px;
display: grid;
width: 160px;
height: 160px;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 8px;
transform: rotate(23.89deg);
}
.icon-tile {
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.12);
}
.icon-tile img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.icon-fade {
position: absolute;
inset: 0 auto 0 210px;
width: 110px;
background: linear-gradient(90deg, var(--surface-3) 0%, transparent 100%);
}
</style>
@@ -0,0 +1,12 @@
const STORAGE_KEY = 'new-icon-editor-notification-shown'
export function shouldShowNewIconEditorNotification(showOnboardingChecklist: boolean): boolean {
try {
if (localStorage.getItem(STORAGE_KEY) === 'true') return false
if (showOnboardingChecklist) return false
localStorage.setItem(STORAGE_KEY, 'true')
return true
} catch {
return !showOnboardingChecklist
}
}