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,466 @@
import { defineMessages } from '@modrinth/ui'
import backpack from '@/assets/instance-icons/backpack.png'
import beacon from '@/assets/instance-icons/beacon.png'
import blueShark from '@/assets/instance-icons/blue-shark.png'
import bookshelf from '@/assets/instance-icons/bookshelf.png'
import brownBear from '@/assets/instance-icons/brown-bear.png'
import cake from '@/assets/instance-icons/cake.png'
import campfire from '@/assets/instance-icons/campfire.png'
import chest from '@/assets/instance-icons/chest.png'
import cogwheel from '@/assets/instance-icons/cogwheel.png'
import commandBlock from '@/assets/instance-icons/command-block.png'
import cookingPot from '@/assets/instance-icons/cooking-pot.png'
import couch from '@/assets/instance-icons/couch.png'
import craftingTable from '@/assets/instance-icons/crafting-table.png'
import creeper from '@/assets/instance-icons/creeper.png'
import enchantingTable from '@/assets/instance-icons/enchanting-table.png'
import enderChest from '@/assets/instance-icons/ender-chest.png'
import enderDragon from '@/assets/instance-icons/ender-dragon.png'
import engine from '@/assets/instance-icons/engine.png'
import furnace from '@/assets/instance-icons/furnace.png'
import gizmo from '@/assets/instance-icons/gizmo.png'
import globe from '@/assets/instance-icons/globe.png'
import grassBlock from '@/assets/instance-icons/grass-block.png'
import lantern from '@/assets/instance-icons/lantern.png'
import moobloom from '@/assets/instance-icons/moobloom.png'
import mrPack from '@/assets/instance-icons/mr-pack.png'
import orb from '@/assets/instance-icons/orb.png'
import oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
import pancakes from '@/assets/instance-icons/pancakes.png'
import pickaxe from '@/assets/instance-icons/pickaxe.png'
import pokeBall from '@/assets/instance-icons/poke-ball.png'
import redstoneBlock from '@/assets/instance-icons/redstone-block.png'
import sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
import skeleton from '@/assets/instance-icons/skeleton.png'
import skillet from '@/assets/instance-icons/skillet.png'
import slimeBlock from '@/assets/instance-icons/slime-block.png'
import spaceHelmet from '@/assets/instance-icons/space-helmet.png'
import stickyPiston from '@/assets/instance-icons/sticky-piston.png'
import sword from '@/assets/instance-icons/sword.png'
import terminal from '@/assets/instance-icons/terminal.png'
import tinyPotato from '@/assets/instance-icons/tiny-potato.png'
import tire from '@/assets/instance-icons/tire.png'
import tnt from '@/assets/instance-icons/tnt.png'
import wrench from '@/assets/instance-icons/wrench.png'
import wrenchRinth from '@/assets/instance-icons/wrench-rinth.png'
import zombie from '@/assets/instance-icons/zombie.png'
const names = defineMessages({
yellow: {
id: 'instance.icon-editor.background.yellow',
defaultMessage: 'Yellow',
},
green: {
id: 'instance.icon-editor.background.green',
defaultMessage: 'Green',
},
lime: {
id: 'instance.icon-editor.background.lime',
defaultMessage: 'Lime',
},
darkGreen: {
id: 'instance.icon-editor.background.dark-green',
defaultMessage: 'Dark green',
},
purple: {
id: 'instance.icon-editor.background.purple',
defaultMessage: 'Purple',
},
blue: {
id: 'instance.icon-editor.background.blue',
defaultMessage: 'Blue',
},
orange: {
id: 'instance.icon-editor.background.orange',
defaultMessage: 'Orange',
},
red: {
id: 'instance.icon-editor.background.red',
defaultMessage: 'Red',
},
rose: {
id: 'instance.icon-editor.background.rose',
defaultMessage: 'Rose',
},
pink: {
id: 'instance.icon-editor.background.pink',
defaultMessage: 'Pink',
},
indigo: {
id: 'instance.icon-editor.background.indigo',
defaultMessage: 'Indigo',
},
lavender: {
id: 'instance.icon-editor.background.lavender',
defaultMessage: 'Lavender',
},
lightGray: {
id: 'instance.icon-editor.background.light-gray',
defaultMessage: 'Light gray',
},
gray: {
id: 'instance.icon-editor.background.gray',
defaultMessage: 'Gray',
},
darkGray: {
id: 'instance.icon-editor.background.dark-gray',
defaultMessage: 'Dark gray',
},
backpack: { id: 'instance.icon-editor.symbol.backpack', defaultMessage: 'Backpack' },
beacon: { id: 'instance.icon-editor.symbol.beacon', defaultMessage: 'Beacon' },
blueShark: { id: 'instance.icon-editor.symbol.blue-shark', defaultMessage: 'Blue Shark' },
bookshelf: { id: 'instance.icon-editor.symbol.bookshelf', defaultMessage: 'Bookshelf' },
brownBear: { id: 'instance.icon-editor.symbol.brown-bear', defaultMessage: 'Brown Bear' },
cake: { id: 'instance.icon-editor.symbol.cake', defaultMessage: 'Cake' },
campfire: { id: 'instance.icon-editor.symbol.campfire', defaultMessage: 'Campfire' },
chest: { id: 'instance.icon-editor.symbol.chest', defaultMessage: 'Chest' },
cogwheel: { id: 'instance.icon-editor.symbol.cogwheel', defaultMessage: 'Cogwheel' },
commandBlock: {
id: 'instance.icon-editor.symbol.command-block',
defaultMessage: 'Command Block',
},
cookingPot: {
id: 'instance.icon-editor.symbol.cooking-pot',
defaultMessage: 'Cooking Pot',
},
couch: { id: 'instance.icon-editor.symbol.couch', defaultMessage: 'Couch' },
craftingTable: {
id: 'instance.icon-editor.symbol.crafting-table',
defaultMessage: 'Crafting Table',
},
creeper: { id: 'instance.icon-editor.symbol.creeper', defaultMessage: 'Creeper' },
enchantingTable: {
id: 'instance.icon-editor.symbol.enchanting-table',
defaultMessage: 'Enchanting Table',
},
enderChest: {
id: 'instance.icon-editor.symbol.ender-chest',
defaultMessage: 'Ender Chest',
},
enderDragon: {
id: 'instance.icon-editor.symbol.ender-dragon',
defaultMessage: 'Ender Dragon',
},
engine: { id: 'instance.icon-editor.symbol.engine', defaultMessage: 'Engine' },
furnace: { id: 'instance.icon-editor.symbol.furnace', defaultMessage: 'Furnace' },
gizmo: { id: 'instance.icon-editor.symbol.gizmo', defaultMessage: 'Gizmo' },
globe: { id: 'instance.icon-editor.symbol.globe', defaultMessage: 'Globe' },
grassBlock: {
id: 'instance.icon-editor.symbol.grass-block',
defaultMessage: 'Grass Block',
},
lantern: { id: 'instance.icon-editor.symbol.lantern', defaultMessage: 'Lantern' },
moobloom: { id: 'instance.icon-editor.symbol.moobloom', defaultMessage: 'Moobloom' },
mrPack: { id: 'instance.icon-editor.symbol.mr-pack', defaultMessage: 'Mr Pack' },
orb: { id: 'instance.icon-editor.symbol.orb', defaultMessage: 'Orb' },
oxygenDistributor: {
id: 'instance.icon-editor.symbol.oxygen-distributor',
defaultMessage: 'Oxygen Distributor',
},
pancakes: { id: 'instance.icon-editor.symbol.pancakes', defaultMessage: 'Pancakes' },
pickaxe: { id: 'instance.icon-editor.symbol.pickaxe', defaultMessage: 'Pickaxe' },
pokeBall: { id: 'instance.icon-editor.symbol.poke-ball', defaultMessage: 'Poke Ball' },
redstoneBlock: {
id: 'instance.icon-editor.symbol.redstone-block',
defaultMessage: 'Redstone Block',
},
sculkSensor: {
id: 'instance.icon-editor.symbol.sculk-sensor',
defaultMessage: 'Sculk Sensor',
},
skeleton: { id: 'instance.icon-editor.symbol.skeleton', defaultMessage: 'Skeleton' },
skillet: { id: 'instance.icon-editor.symbol.skillet', defaultMessage: 'Skillet' },
slimeBlock: {
id: 'instance.icon-editor.symbol.slime-block',
defaultMessage: 'Slime Block',
},
spaceHelmet: {
id: 'instance.icon-editor.symbol.space-helmet',
defaultMessage: 'Space Helmet',
},
stickyPiston: {
id: 'instance.icon-editor.symbol.sticky-piston',
defaultMessage: 'Sticky Piston',
},
sword: { id: 'instance.icon-editor.symbol.sword', defaultMessage: 'Sword' },
tnt: { id: 'instance.icon-editor.symbol.tnt', defaultMessage: 'TNT' },
terminal: { id: 'instance.icon-editor.symbol.terminal', defaultMessage: 'Terminal' },
tinyPotato: {
id: 'instance.icon-editor.symbol.tiny-potato',
defaultMessage: 'Tiny Potato',
},
tire: { id: 'instance.icon-editor.symbol.tire', defaultMessage: 'Tire' },
wrench: { id: 'instance.icon-editor.symbol.create-wrench', defaultMessage: 'Wrench' },
wrenchRinth: {
id: 'instance.icon-editor.symbol.wrenth-rinth',
defaultMessage: 'Modrinth Wrench',
},
zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' },
})
export const backgroundOptions = [
{
id: 'rose',
background: {
type: 'linear-top-down-gradient',
top_color: '#D62E63',
bottom_color: '#F95C62',
},
name: names.rose,
},
{
id: 'orange',
background: {
type: 'linear-top-down-gradient',
top_color: '#FF8D29',
bottom_color: '#FFB452',
},
name: names.orange,
},
{
id: 'yellow',
background: {
type: 'linear-top-down-gradient',
top_color: '#FFC629',
bottom_color: '#FFEE53',
},
name: names.yellow,
},
{
id: 'lime',
background: {
type: 'linear-top-down-gradient',
top_color: '#6FDA1D',
bottom_color: '#CBFF50',
},
name: names.lime,
},
{
id: 'green',
background: {
type: 'linear-top-down-gradient',
top_color: '#0B9F21',
bottom_color: '#4FD24B',
},
name: names.green,
},
// {
// id: 'dark_green',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#084C13',
// bottom_color: '#327735',
// },
// name: names.darkGreen,
// },
// {
// id: 'indigo',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#3F00D3',
// bottom_color: '#2659FE',
// },
// name: names.indigo,
// },
{
id: 'purple',
background: {
type: 'linear-top-down-gradient',
top_color: '#4739FF',
bottom_color: '#6670FF',
},
name: names.purple,
},
{
id: 'blue',
background: {
type: 'linear-top-down-gradient',
top_color: '#227EFF',
bottom_color: '#5EC1FF',
},
name: names.blue,
},
{
id: 'lavender',
background: {
type: 'linear-top-down-gradient',
top_color: '#C056FD',
bottom_color: '#B889FF',
},
name: names.lavender,
},
{
id: 'pink',
background: {
type: 'linear-top-down-gradient',
top_color: '#F640C0',
bottom_color: '#FF7BF1',
},
name: names.pink,
},
// {
// id: 'red',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#F6111C',
// bottom_color: '#F94548',
// },
// name: names.red,
// },
{
id: 'light_gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#AEAEAE',
bottom_color: '#D9D9D9',
},
name: names.lightGray,
},
{
id: 'gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#373C4C',
bottom_color: '#4C4F58',
},
name: names.gray,
},
{
id: 'dark_gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#1B1D29',
bottom_color: '#252731',
},
name: names.darkGray,
},
] as const
export const symbolOptions = [
// Cobblemon: Poké Ball
{ id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' },
// Origins: Orb of Origins
{ id: 'orb', name: names.orb, asset: orb, category: 'modded' },
// Farmer's Delight: Cooking Pot, Skillet
{ id: 'cooking_pot', name: names.cookingPot, asset: cookingPot, category: 'modded' },
{ id: 'skillet', name: names.skillet, asset: skillet, category: 'modded' },
// Supplementaries: Globe, Pancakes
{ id: 'globe', name: names.globe, asset: globe, category: 'modded' },
{ id: 'pancakes', name: names.pancakes, asset: pancakes, category: 'modded' },
// Sophisticated Backpacks: Backpack
{ id: 'backpack', name: names.backpack, asset: backpack, category: 'modded' },
// Chipped: Chair
{ id: 'couch', name: names.couch, asset: couch, category: 'modded' },
// Botania: Tiny Potato
{ id: 'tiny_potato', name: names.tinyPotato, asset: tinyPotato, category: 'modded' },
// Blåhaj: Blue Shark
{ id: 'blue_shark', name: names.blueShark, asset: blueShark, category: 'modded' },
// Other modded symbols: Brown Bear, Moobloom
{ id: 'brown_bear', name: names.brownBear, asset: brownBear, category: 'modded' },
{ id: 'moobloom', name: names.moobloom, asset: moobloom, category: 'modded' },
// Create: Wrench, Cogwheel
{ id: 'create_wrench', name: names.wrench, asset: wrench, category: 'modded' },
{ id: 'cogwheel', name: names.cogwheel, asset: cogwheel, category: 'modded' },
// Create Aeronautics: Engine, Tire
{ id: 'engine', name: names.engine, asset: engine, category: 'modded' },
{ id: 'tire', name: names.tire, asset: tire, category: 'modded' },
// Ad Astra: Oxygen Distributor, Space Helmet
{
id: 'oxygen_distributor',
name: names.oxygenDistributor,
asset: oxygenDistributor,
category: 'modded',
},
{ id: 'space_helmet', name: names.spaceHelmet, asset: spaceHelmet, category: 'modded' },
// Miscellaneous: Gizmo, Terminal
{ id: 'gizmo', name: names.gizmo, asset: gizmo, category: 'modded' },
{ id: 'terminal', name: names.terminal, asset: terminal, category: 'modded' },
// Miscellaneous: Modrinth Wrench, Mr Pack
{ id: 'wrenth_rinth', name: names.wrenchRinth, asset: wrenchRinth, category: 'modded' },
{ id: 'mr_pack', name: names.mrPack, asset: mrPack, category: 'modded' },
/////////////////////////
// vanilla ones
/////////////////////////
{ id: 'grass_block', name: names.grassBlock, asset: grassBlock, category: 'vanilla' },
{ id: 'crafting_table', name: names.craftingTable, asset: craftingTable, category: 'vanilla' },
{ id: 'furnace', name: names.furnace, asset: furnace, category: 'vanilla' },
{ id: 'chest', name: names.chest, asset: chest, category: 'vanilla' },
{ id: 'bookshelf', name: names.bookshelf, asset: bookshelf, category: 'vanilla' },
{
id: 'redstone_block',
name: names.redstoneBlock,
asset: redstoneBlock,
category: 'vanilla',
},
{
id: 'sticky_piston',
name: names.stickyPiston,
asset: stickyPiston,
category: 'vanilla',
},
{ id: 'slime_block', name: names.slimeBlock, asset: slimeBlock, category: 'vanilla' },
{ id: 'cake', name: names.cake, asset: cake, category: 'vanilla' },
{ id: 'campfire', name: names.campfire, asset: campfire, category: 'vanilla' },
{ id: 'pickaxe', name: names.pickaxe, asset: pickaxe, category: 'vanilla' },
{ id: 'sword', name: names.sword, asset: sword, category: 'vanilla' },
{ id: 'zombie', name: names.zombie, asset: zombie, category: 'vanilla' },
{ id: 'creeper', name: names.creeper, asset: creeper, category: 'vanilla' },
{ id: 'skeleton', name: names.skeleton, asset: skeleton, category: 'vanilla' },
{ id: 'ender_dragon', name: names.enderDragon, asset: enderDragon, category: 'vanilla' },
{ id: 'ender_chest', name: names.enderChest, asset: enderChest, category: 'vanilla' },
{ id: 'sculk_sensor', name: names.sculkSensor, asset: sculkSensor, category: 'vanilla' },
{ id: 'beacon', name: names.beacon, asset: beacon, category: 'vanilla' },
{
id: 'enchanting_table',
name: names.enchantingTable,
asset: enchantingTable,
category: 'vanilla',
},
{ id: 'lantern', name: names.lantern, asset: lantern, category: 'vanilla' },
{ id: 'tnt', name: names.tnt, asset: tnt, category: 'vanilla' },
{ id: 'command_block', name: names.commandBlock, asset: commandBlock, category: 'vanilla' },
] as const
export type BackgroundId = (typeof backgroundOptions)[number]['id']
export type SymbolId = (typeof symbolOptions)[number]['id']
export const RANDOM_CONFIG_BLACKLIST = [
{ background: 'purple', symbol: 'globe' },
{ background: 'blue', symbol: 'globe' },
{ background: 'gray', symbol: 'cogwheel' },
{ background: 'dark_gray', symbol: 'cogwheel' },
{ background: 'rose', symbol: 'poke_ball' },
{ background: 'lime', symbol: 'slime_block' },
{ background: 'green', symbol: 'slime_block' },
{ background: 'rose', symbol: 'redstone_block' },
{ background: 'rose', symbol: 'couch' },
{ background: 'orange', symbol: 'space_helmet' },
{ background: 'rose', symbol: 'tnt' },
{ background: 'yellow', symbol: 'moobloom' },
{ background: 'green', symbol: 'wrenth_rinth' },
{ background: 'lime', symbol: 'mr_pack' },
{ background: 'light_gray', symbol: 'skillet' },
{ background: 'light_gray', symbol: 'cooking_pot' },
] satisfies readonly { background: BackgroundId; symbol: SymbolId }[]
export const DEFAULT_BACKGROUND_ID = 'purple' satisfies BackgroundId
export const DEFAULT_SYMBOL_ID = 'grass_block' satisfies SymbolId
@@ -0,0 +1,537 @@
<script setup lang="ts">
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { toError } from '@/helpers/errors'
import {
cache_generated_icon,
edit_generated_icon,
edit_generated_icon_if_empty,
get_recent_icon_configs,
} from '@/helpers/instance'
import type { IconBackground, InstanceIconConfig } from '@/helpers/types'
import {
type BackgroundId,
backgroundOptions,
DEFAULT_BACKGROUND_ID,
DEFAULT_SYMBOL_ID,
RANDOM_CONFIG_BLACKLIST,
type SymbolId,
symbolOptions,
} from './editor-catalog'
const props = defineProps<{
instanceId?: string
config?: InstanceIconConfig | null
}>()
const emit = defineEmits<{
saved: [iconPath: string, config: InstanceIconConfig]
}>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const recentConfigs = ref<InstanceIconConfig[]>([])
const saving = ref(false)
const backgroundScroller = ref<HTMLElement | null>(null)
const showLeftBackgroundShadow = ref(false)
const showRightBackgroundShadow = ref(false)
const draggingBackgrounds = ref(false)
let backgroundScrollerResizeObserver: ResizeObserver | null = null
let backgroundDragPointerId: number | null = null
let backgroundDragCaptureTarget: Element | null = null
let backgroundDragStartX = 0
let backgroundDragStartScrollLeft = 0
let suppressBackgroundClick = false
let suppressBackgroundClickTimeout: ReturnType<typeof setTimeout> | null = null
const selectedBackground = ref<BackgroundId>(DEFAULT_BACKGROUND_ID)
const selectedSymbol = ref<SymbolId>(DEFAULT_SYMBOL_ID)
const selectedBackgroundOption = computed(
() => backgroundOptions.find((option) => option.id === selectedBackground.value)!,
)
const selectedSymbolOption = computed(
() => symbolOptions.find((option) => option.id === selectedSymbol.value)!,
)
const vanillaSymbolStartIndex = symbolOptions.findIndex((option) => option.category === 'vanilla')
const selectedConfig = computed<InstanceIconConfig>(() => ({
background: { ...selectedBackgroundOption.value.background },
symbol: selectedSymbol.value,
}))
const visibleRecentConfigs = computed(() =>
recentConfigs.value.filter(
(config) => backgroundOption(config.background) && symbolOption(config.symbol),
),
)
function updateBackgroundScrollShadows() {
const el = backgroundScroller.value
if (!el) {
showLeftBackgroundShadow.value = false
showRightBackgroundShadow.value = false
return
}
showLeftBackgroundShadow.value = el.scrollLeft > 0
showRightBackgroundShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
}
function onBackgroundWheel(event: WheelEvent) {
const el = backgroundScroller.value
if (!el || el.scrollWidth <= el.clientWidth) return
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
el.scrollLeft += delta
}
function onBackgroundPointerDown(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerType === 'touch' || event.button !== 0) return
backgroundDragPointerId = event.pointerId
backgroundDragStartX = event.clientX
backgroundDragStartScrollLeft = el.scrollLeft
suppressBackgroundClick = false
backgroundDragCaptureTarget =
event.target instanceof Element ? (event.target.closest('button') ?? el) : el
backgroundDragCaptureTarget.setPointerCapture(event.pointerId)
}
function onBackgroundPointerMove(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerId !== backgroundDragPointerId) return
const distance = event.clientX - backgroundDragStartX
if (!draggingBackgrounds.value && Math.abs(distance) < 4) return
draggingBackgrounds.value = true
suppressBackgroundClick = true
event.preventDefault()
el.scrollLeft = backgroundDragStartScrollLeft - distance
}
function finishBackgroundDrag(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerId !== backgroundDragPointerId) return
if (backgroundDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
backgroundDragCaptureTarget.releasePointerCapture(event.pointerId)
}
backgroundDragPointerId = null
backgroundDragCaptureTarget = null
draggingBackgrounds.value = false
if (suppressBackgroundClick) {
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
suppressBackgroundClickTimeout = setTimeout(() => {
suppressBackgroundClick = false
suppressBackgroundClickTimeout = null
}, 0)
}
}
function onBackgroundClick(event: MouseEvent) {
if (!suppressBackgroundClick) return
event.preventDefault()
event.stopPropagation()
suppressBackgroundClick = false
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
suppressBackgroundClickTimeout = null
}
onMounted(() => {
backgroundScrollerResizeObserver = new ResizeObserver(updateBackgroundScrollShadows)
if (backgroundScroller.value) backgroundScrollerResizeObserver.observe(backgroundScroller.value)
nextTick(updateBackgroundScrollShadows)
})
onBeforeUnmount(() => {
backgroundScrollerResizeObserver?.disconnect()
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
})
function backgroundOption(background?: IconBackground) {
if (background?.type !== 'linear-top-down-gradient') return undefined
return backgroundOptions.find(
(option) =>
option.background.top_color === background.top_color &&
option.background.bottom_color === background.bottom_color,
)
}
function backgroundStyle(background: IconBackground) {
if (background.type === 'color') return { backgroundColor: background.value }
return {
backgroundImage: `linear-gradient(to bottom, ${background.top_color}, ${background.bottom_color})`,
}
}
function backgroundKey(background: IconBackground) {
if (background.type === 'color') return `${background.type}-${background.value}`
return `${background.type}-${background.top_color}-${background.bottom_color}`
}
function symbolOption(symbol: string) {
return symbolOptions.find((option) => option.id === symbol)
}
function selectRecent(config: InstanceIconConfig) {
const background = backgroundOption(config.background)
const symbol = symbolOption(config.symbol)
if (!background || !symbol) return
selectedBackground.value = background.id
selectedSymbol.value = symbol.id
}
function surpriseMe() {
const configurations = backgroundOptions.flatMap((background) =>
symbolOptions
.filter(
(symbol) =>
background.id !== selectedBackground.value &&
symbol.id !== selectedSymbol.value &&
!RANDOM_CONFIG_BLACKLIST.some(
(config) => config.background === background.id && config.symbol === symbol.id,
),
)
.map((symbol) => ({ background: background.id, symbol: symbol.id })),
)
const configuration = configurations[Math.floor(Math.random() * configurations.length)]
if (!configuration) return
selectedBackground.value = configuration.background
selectedSymbol.value = configuration.symbol
}
async function loadRecents() {
try {
recentConfigs.value = await get_recent_icon_configs()
} catch (error) {
handleError(toError(error))
}
}
function show() {
selectedBackground.value = backgroundOption(props.config?.background)?.id ?? DEFAULT_BACKGROUND_ID
selectedSymbol.value = symbolOption(props.config?.symbol ?? '')?.id ?? DEFAULT_SYMBOL_ID
modal.value?.show()
void loadRecents()
nextTick(updateBackgroundScrollShadows)
}
function hide() {
modal.value?.hide()
}
async function loadSymbolBytes(asset: string): Promise<number[]> {
const response = await fetch(asset)
if (!response.ok) throw new Error('Failed to load the icon symbol.')
return Array.from(new Uint8Array(await response.arrayBuffer()))
}
async function saveIcon() {
if (saving.value) return
saving.value = true
try {
const config = selectedConfig.value
const symbolBytes = await loadSymbolBytes(selectedSymbolOption.value.asset)
const iconPath = props.instanceId
? await edit_generated_icon(props.instanceId, config, symbolBytes)
: await cache_generated_icon(config, symbolBytes, true)
emit('saved', iconPath, config)
saving.value = false
await nextTick()
hide()
} catch (error) {
handleError(toError(error))
} finally {
saving.value = false
}
}
async function randomizeAndSave() {
try {
surpriseMe()
const config = selectedConfig.value
const iconPath = await cache_generated_icon(
config,
await loadSymbolBytes(selectedSymbolOption.value.asset),
)
return { iconPath, config }
} catch (error) {
handleError(toError(error))
return null
}
}
async function applyGeneratedIcon(instanceId: string, config: InstanceIconConfig) {
try {
const symbol = symbolOption(config.symbol)
if (!backgroundOption(config.background) || !symbol) return false
await edit_generated_icon_if_empty(instanceId, config, await loadSymbolBytes(symbol.asset))
return true
} catch (error) {
handleError(toError(error))
return false
}
}
defineExpose({ show, hide, randomize: randomizeAndSave, randomizeAndSave, applyGeneratedIcon })
const messages = defineMessages({
title: {
id: 'instance.icon-editor.title',
defaultMessage: 'Icon editor',
},
background: {
id: 'instance.icon-editor.background',
defaultMessage: 'Background',
},
symbol: {
id: 'instance.icon-editor.symbol',
defaultMessage: 'Symbol',
},
surpriseMe: {
id: 'instance.icon-editor.surprise-me',
defaultMessage: 'Randomize',
},
recents: {
id: 'instance.icon-editor.recents',
defaultMessage: 'Recents',
},
description: {
id: 'instance.icon-editor.description',
defaultMessage: 'Mix and match elements to create a custom icon.',
},
saveIcon: {
id: 'instance.icon-editor.save',
defaultMessage: 'Save icon',
},
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="928px"
max-width="calc(100vw - 2rem)"
no-padding
actions-divider
:disable-close="saving"
>
<div class="flex h-[552px] max-h-[calc(100vh-168px)] min-h-0">
<aside
class="flex w-[244px] shrink-0 flex-col gap-4 overflow-y-auto border-0 border-r border-solid border-surface-5 p-6"
>
<div
class="flex w-full flex-col items-center gap-3 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-4"
>
<div
class="icon-outline relative size-[132px] overflow-hidden rounded-[20px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div class="flex items-center gap-2.5">
<div
class="icon-outline relative size-12 overflow-hidden rounded-2xl"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-8 overflow-hidden rounded-[10px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-4 overflow-hidden rounded-[5px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
</div>
</div>
<Button class="w-full !shadow-none" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</Button>
<div v-if="visibleRecentConfigs.length" class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">{{ formatMessage(messages.recents) }}</span>
<div class="grid grid-cols-4 gap-3">
<button
v-for="(recentConfig, index) in visibleRecentConfigs"
:key="`${backgroundKey(recentConfig.background)}-${recentConfig.symbol}`"
class="icon-outline relative size-10 cursor-pointer overflow-hidden rounded-xl border-0 p-0 transition-transform hover:scale-105"
:style="backgroundStyle(recentConfig.background)"
:aria-label="`${formatMessage(messages.recents)} ${index + 1}`"
@click="selectRecent(recentConfig)"
>
<img
:src="symbolOption(recentConfig.symbol)?.asset"
alt=""
class="size-full object-cover"
/>
</button>
</div>
</div>
</aside>
<div class="min-w-0 flex-1 overflow-y-auto bg-surface-2">
<section class="border-0 border-b border-solid border-surface-5 p-4">
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.background) }}
</h3>
<div class="relative">
<div
class="background-scroll-shadow-left pointer-events-none absolute bottom-0 -left-0.5 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
:class="showLeftBackgroundShadow ? 'opacity-100' : 'opacity-0'"
/>
<div
ref="backgroundScroller"
class="flex w-full select-none gap-2.5 overflow-x-auto overflow-y-hidden pb-2 pr-6"
:class="{ 'cursor-grabbing': draggingBackgrounds }"
@pointerdown="onBackgroundPointerDown"
@pointermove="onBackgroundPointerMove"
@pointerup="finishBackgroundDrag"
@pointercancel="finishBackgroundDrag"
@click.capture="onBackgroundClick"
@wheel.prevent="onBackgroundWheel"
@scroll="updateBackgroundScrollShadows"
>
<button
v-for="option in backgroundOptions"
:key="option.id"
class="icon-option icon-outline relative aspect-square w-[calc((100%_-_3.125rem)/6)] shrink-0 cursor-pointer rounded-[20px] border-0 p-0"
:class="{ 'icon-outline-selected': selectedBackground === option.id }"
:style="backgroundStyle(option.background)"
:aria-label="formatMessage(option.name)"
:aria-pressed="selectedBackground === option.id"
@click="selectedBackground = option.id"
>
<span
v-if="selectedBackground === option.id"
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
>
<CheckIcon class="size-4" />
</span>
</button>
</div>
<div
class="background-scroll-shadow-right pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
:class="showRightBackgroundShadow ? 'opacity-100' : 'opacity-0'"
/>
</div>
</section>
<section class="p-4">
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.symbol) }}
</h3>
<div class="grid grid-cols-6 gap-2.5">
<template v-for="(option, index) in symbolOptions" :key="option.id">
<hr
v-if="index === vanillaSymbolStartIndex"
class="col-span-6 my-2.5 mx-1 w-full border-0 border-t border-solid border-surface-5"
/>
<button
v-tooltip="{
content: formatMessage(option.name),
delay: { show: 500, hide: 0 },
}"
class="icon-option icon-outline relative aspect-square cursor-pointer overflow-hidden rounded-[20px] border-0 bg-transparent p-0"
:class="{ 'icon-outline-selected': selectedSymbol === option.id }"
:aria-label="formatMessage(option.name)"
:aria-pressed="selectedSymbol === option.id"
@click="selectedSymbol = option.id"
>
<img :src="option.asset" alt="" class="size-full object-cover" />
<span
v-if="selectedSymbol === option.id"
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
>
<CheckIcon class="size-4" />
</span>
</button>
</template>
</div>
</section>
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4 px-2">
<div class="flex min-w-0 items-center gap-2 text-primary">
<InfoIcon class="size-6 shrink-0 text-blue" />
<span>{{ formatMessage(messages.description) }}</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button :disabled="saving" type="outlined" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="saving" @click="saveIcon">
<SpinnerIcon v-if="saving" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(messages.saveIcon) }}
</Button>
</div>
</div>
</template>
</NewModal>
</template>
<style scoped>
.icon-outline {
outline: 1px solid color-mix(in srgb, var(--color-text-primary) 15%, transparent);
outline-offset: -1px;
}
.icon-option:not(.icon-outline-selected):hover {
outline-color: color-mix(in srgb, var(--color-text-primary) 30%, transparent);
}
.icon-option {
transition: outline-color 150ms ease;
}
.cursor-grabbing,
.cursor-grabbing * {
cursor: grabbing !important;
}
.icon-outline-selected {
outline-color: color-mix(in srgb, var(--color-text-primary) 60%, transparent);
}
.background-scroll-shadow-left {
-webkit-mask-image: linear-gradient(to right, black, transparent);
mask-image: linear-gradient(to right, black, transparent);
}
.background-scroll-shadow-right {
-webkit-mask-image: linear-gradient(to left, black, transparent);
mask-image: linear-gradient(to left, black, transparent);
}
</style>