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
@@ -72,9 +72,6 @@ const props = withDefaults(
{
type: 'standard',
openByDefault: false,
buttonClass: null,
contentClass: null,
titleWrapperClass: null,
forceOpen: false,
overflowVisible: false,
divider: false,
+152 -5
View File
@@ -6,14 +6,16 @@
:style="`--_size: ${cssSize}`"
:class="{
circle: circle,
detecting: !hasDetectedCorners,
'no-shadow': noShadow,
padded: hasTransparentCorners,
raised: raised,
pixelated: pixelated,
}"
:src="src"
:alt="alt"
:loading="loading"
@load="updatePixelated"
@load="onLoad"
@error="onError"
/>
<svg
@@ -46,11 +48,22 @@
</template>
<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
const pixelated = ref(false)
const hasTransparentCorners = ref(false)
const hasDetectedCorners = ref(false)
const img = useTemplateRef<HTMLImageElement>('img')
const failed = ref(false)
let detectionTimeout: number | undefined
let detectingSource: string | undefined
const DETECTION_TIMEOUT_MS = 2000
const CORNER_SAMPLE_SIZE = 8
defineExpose({
failed,
})
const props = withDefaults(
defineProps<{
@@ -88,21 +101,146 @@ const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
watch(
() => props.src,
() => {
clearDetectionTimeout()
detectingSource = undefined
failed.value = false
hasTransparentCorners.value = false
hasDetectedCorners.value = false
},
)
onMounted(() => {
const image = img.value
if (!image?.complete || hasDetectedCorners.value) return
if (image.naturalWidth > 0) {
onLoad()
} else {
failed.value = true
}
})
onBeforeUnmount(clearDetectionTimeout)
function clearDetectionTimeout() {
if (detectionTimeout !== undefined) {
window.clearTimeout(detectionTimeout)
detectionTimeout = undefined
}
}
function onError(e) {
clearDetectionTimeout()
detectingSource = undefined
console.log('Avatar image failed to load:', props.src, e)
failed.value = true
}
function updatePixelated() {
if (img.value && img.value.naturalWidth && img.value.naturalWidth < 32) {
function onLoad() {
const image = img.value
if (!image) return
const source = image.currentSrc
if (detectingSource === source) return
detectingSource = source
clearDetectionTimeout()
if (image.naturalWidth && image.naturalWidth < 32) {
pixelated.value = true
} else {
pixelated.value = false
}
if (canReadImagePixels(source)) {
const transparentCorners = detectTransparentCorners(image)
if (transparentCorners !== null) {
detectingSource = undefined
hasTransparentCorners.value = transparentCorners
hasDetectedCorners.value = true
return
}
}
const probe = new Image()
let detectionFinished = false
probe.crossOrigin = 'anonymous'
probe.onload = () => {
if (detectionFinished || img.value?.currentSrc !== source) return
detectionFinished = true
detectingSource = undefined
clearDetectionTimeout()
hasTransparentCorners.value = detectTransparentCorners(probe) ?? false
hasDetectedCorners.value = true
}
probe.onerror = () => {
if (detectionFinished || img.value?.currentSrc !== source) return
detectionFinished = true
detectingSource = undefined
clearDetectionTimeout()
hasDetectedCorners.value = true
}
detectionTimeout = window.setTimeout(() => {
if (img.value?.currentSrc !== source) return
detectionFinished = true
detectingSource = undefined
detectionTimeout = undefined
hasDetectedCorners.value = true
}, DETECTION_TIMEOUT_MS)
probe.src = source
}
function canReadImagePixels(source: string): boolean {
try {
const url = new URL(source, window.location.href)
return url.protocol === 'data:' || url.origin === window.location.origin
} catch {
return false
}
}
function detectTransparentCorners(image: HTMLImageElement): boolean | null {
if (image.naturalWidth < CORNER_SAMPLE_SIZE || image.naturalHeight < CORNER_SAMPLE_SIZE) {
return false
}
const canvas = document.createElement('canvas')
canvas.width = CORNER_SAMPLE_SIZE * 2
canvas.height = CORNER_SAMPLE_SIZE * 2
const context = canvas.getContext('2d', { willReadFrequently: true })
if (!context) return false
const right = image.naturalWidth - CORNER_SAMPLE_SIZE
const bottom = image.naturalHeight - CORNER_SAMPLE_SIZE
const drawCorner = (
sourceX: number,
sourceY: number,
destinationX: number,
destinationY: number,
) =>
context.drawImage(
image,
sourceX,
sourceY,
CORNER_SAMPLE_SIZE,
CORNER_SAMPLE_SIZE,
destinationX,
destinationY,
CORNER_SAMPLE_SIZE,
CORNER_SAMPLE_SIZE,
)
try {
drawCorner(0, 0, 0, 0)
drawCorner(right, 0, CORNER_SAMPLE_SIZE, 0)
drawCorner(0, bottom, 0, CORNER_SAMPLE_SIZE)
drawCorner(right, bottom, CORNER_SAMPLE_SIZE, CORNER_SAMPLE_SIZE)
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data
for (let alpha = 3; alpha < pixels.length; alpha += 4) {
if (pixels[alpha] !== 0) return false
}
return true
} catch {
return null
}
}
const tint = computed(() => {
@@ -128,7 +266,8 @@ function hash(str: string): number {
.avatar {
--_size: 2rem;
border: 1px solid var(--surface-5);
outline: 1px solid rgb(255 255 255 / 15%);
outline-offset: -1px;
background-color: var(--color-button-bg);
object-fit: contain;
border-radius: calc(16 / 96 * var(--_override-size, var(--_size)));
@@ -154,6 +293,14 @@ function hash(str: string): number {
image-rendering: pixelated;
}
&.detecting {
visibility: hidden;
}
&.padded {
padding: calc(6 / 96 * var(--_override-size, var(--_size)));
}
&.raised {
background-color: var(--color-raised-bg);
}
@@ -2,10 +2,10 @@
<span
v-if="showLabel"
class="flex h-10 items-center text-nowrap text-base font-medium text-primary"
:aria-label="useFilterIcon ? label : undefined"
:aria-label="useFilterIcon ? effectiveLabel : undefined"
>
<FilterIcon v-if="useFilterIcon" class="size-5 text-primary" aria-hidden="true" />
<template v-else>{{ label }}</template>
<template v-else>{{ effectiveLabel }}</template>
</span>
<template v-for="preview in appliedFilterPreviews" :key="preview.key">
@@ -15,7 +15,7 @@
native-type="button"
size="lg"
:class="effectivePreviewTriggerClass"
:aria-label="`Clear ${preview.label} filter`"
:aria-label="formatMessage(messages.clearNamedFilter, { name: preview.label })"
@click="clearFilterCategory(preview.key)"
>
<span class="font-semibold">{{ preview.label }}</span>
@@ -84,7 +84,7 @@
<button
type="button"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-secondary transition-colors hover:text-contrast"
:aria-label="`Clear ${preview.label} filter`"
:aria-label="formatMessage(messages.clearNamedFilter, { name: preview.label })"
@click.stop="clearFilterCategory(preview.key)"
>
<XIcon class="size-4 text-primary" />
@@ -142,11 +142,11 @@
@keydown="handleAddMenuTriggerKeydown"
>
<PlusIcon />
{{ addLabel }}
{{ effectiveAddLabel }}
</Button>
<Button v-if="shouldShowClear" type="quiet" native-type="button" @click="clearAllFilters">{{
clearLabel
effectiveClearLabel
}}</Button>
</div>
@@ -226,7 +226,9 @@
v-model="categorySearchQuery"
:icon="SearchIcon"
type="text"
:placeholder="activeCategory.searchPlaceholder ?? 'Search...'"
:placeholder="
activeCategory.searchPlaceholder ?? formatMessage(messages.searchPlaceholder)
"
wrapper-class="grow bg-surface-4 mx-1"
input-class="ps-9 mx-1.5"
/>
@@ -327,7 +329,11 @@
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors hover:text-contrast"
@click="toggleSectionHeaderOptions(item)"
>
{{ areSectionHeaderOptionsSelected(item) ? 'Clear' : 'Select all' }}
{{
formatMessage(
areSectionHeaderOptionsSelected(item) ? messages.clear : messages.selectAll,
)
}}
</button>
</div>
<button
@@ -423,6 +429,7 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Button, type ButtonElementHandle, type ButtonSize } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import MultiSelect, { type MultiSelectItem } from './MultiSelect.vue'
import StyledInput from './StyledInput.vue'
@@ -539,6 +546,21 @@ const TAILWIND_WIDTH_CLASS_SIZE: Record<string, string> = {
[DEFAULT_SUBMENU_CLASS]: DEFAULT_SUBMENU_WIDTH,
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
filteredBy: { id: 'filter-bar.filtered-by', defaultMessage: 'Filtered by' },
add: { id: 'filter-bar.add', defaultMessage: 'Add' },
clear: { id: 'filter-bar.clear', defaultMessage: 'Clear' },
selectAll: { id: 'filter-bar.select-all', defaultMessage: 'Select all' },
noOptions: { id: 'filter-bar.no-options', defaultMessage: 'No options available.' },
noSearchResults: { id: 'filter-bar.no-search-results', defaultMessage: 'No options found.' },
searchPlaceholder: { id: 'filter-bar.search-placeholder', defaultMessage: 'Search...' },
clearNamedFilter: {
id: 'filter-bar.clear-named-filter',
defaultMessage: 'Clear {name} filter',
},
})
const props = withDefaults(
defineProps<{
modelValue: DropdownFilterBarValue
@@ -559,21 +581,26 @@ const props = withDefaults(
checkboxPosition?: 'left' | 'right'
}>(),
{
label: 'Filtered by',
addLabel: 'Add',
clearLabel: 'Clear',
showClear: false,
showLabel: true,
useFilterIcon: false,
addButtonSize: 'md',
applyImmediately: false,
showPreviewFilterIcon: false,
emptyOptionsLabel: 'No options available.',
emptySearchLabel: 'No options found.',
checkboxPosition: 'left',
},
)
const effectiveLabel = computed(() => props.label ?? formatMessage(messages.filteredBy))
const effectiveAddLabel = computed(() => props.addLabel ?? formatMessage(messages.add))
const effectiveClearLabel = computed(() => props.clearLabel ?? formatMessage(messages.clear))
const effectiveEmptyOptionsLabel = computed(
() => props.emptyOptionsLabel ?? formatMessage(messages.noOptions),
)
const effectiveEmptySearchLabel = computed(
() => props.emptySearchLabel ?? formatMessage(messages.noSearchResults),
)
const emit = defineEmits<{
'update:modelValue': [value: DropdownFilterBarValue]
clear: []
@@ -737,12 +764,12 @@ const activeCategoryOptionsListStyle = computed<CSSProperties | undefined>(() =>
const activeCategoryEmptyStateLabel = computed(() => {
const category = activeCategory.value
if (!category) {
return props.emptyOptionsLabel
return effectiveEmptyOptionsLabel.value
}
return category.searchable && categorySearchQuery.value.trim().length > 0
? (category.emptySearchLabel ?? props.emptySearchLabel)
: (category.emptyOptionsLabel ?? props.emptyOptionsLabel)
? (category.emptySearchLabel ?? effectiveEmptySearchLabel.value)
: (category.emptyOptionsLabel ?? effectiveEmptyOptionsLabel.value)
})
const submenuStyle = computed<CSSProperties>(() => {
@@ -788,9 +815,9 @@ const appliedFilterPreviews = computed(() =>
const hasAppliedFilters = computed(() => appliedFilterPreviews.value.length > 0)
const shouldShowClear = computed(() => hasAppliedFilters.value || props.showClear)
const DEFAULT_PREVIEW_TRIGGER_CLASS = 'max-w-[16rem]'
const DEFAULT_PREVIEW_TRIGGER_CLASS = 'max-w-[16rem] !rounded-xl'
const effectivePreviewTriggerClass = computed(
() => props.previewTriggerClass ?? DEFAULT_PREVIEW_TRIGGER_CLASS,
() => (props.previewTriggerClass ?? DEFAULT_PREVIEW_TRIGGER_CLASS) + ' h-9',
)
function cloneSelectedFilters(filters: DropdownFilterBarValue): DropdownFilterBarValue {
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { EditIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
const model = defineModel<string>({ default: '' })
const props = withDefaults(
defineProps<{
placeholder?: string
defaultValue?: string
maxWidth?: string
maxLength?: number
editLabel?: string
activationMode?: 'text' | 'icon' | 'manual'
inputClass?: string
buttonClass?: string
iconTextClass?: string
validate?: (value: string) => boolean
onChange?: (value: string) => boolean | void | Promise<boolean | void>
}>(),
{
placeholder: '',
defaultValue: '',
maxWidth: '100%',
maxLength: undefined,
editLabel: 'Edit',
activationMode: 'text',
validate: undefined,
onChange: undefined,
},
)
const isEditing = ref(false)
const isSaving = ref(false)
const isInvalid = ref(false)
const input = ref<HTMLInputElement>()
const isEditIconVisible = ref(false)
const draft = ref(model.value)
const originalValue = ref('')
let editIconHideTimeout: ReturnType<typeof setTimeout> | undefined
const displayValue = computed(() => model.value || props.defaultValue || props.placeholder)
const sizingValue = computed(() => {
if (isEditing.value) {
return draft.value || props.placeholder || ' '
}
return displayValue.value || ' '
})
watch(model, (value) => {
if (!isEditing.value) {
draft.value = value
}
})
function clearEditIconHideTimeout() {
if (editIconHideTimeout) {
clearTimeout(editIconHideTimeout)
editIconHideTimeout = undefined
}
}
function showEditIcon() {
isEditIconVisible.value = true
}
function keepEditIconVisible() {
clearEditIconHideTimeout()
isEditIconVisible.value = true
}
function hideEditIcon() {
if (!editIconHideTimeout) {
isEditIconVisible.value = false
}
}
function handleEditIconMouseleave() {
clearEditIconHideTimeout()
editIconHideTimeout = setTimeout(() => {
isEditIconVisible.value = false
editIconHideTimeout = undefined
}, 1000)
}
async function startEditing() {
if (isEditing.value) return
clearEditIconHideTimeout()
originalValue.value = model.value
draft.value = model.value
isInvalid.value = false
isEditing.value = true
await nextTick()
input.value?.focus()
input.value?.select()
}
async function applyValue() {
if (!isEditing.value || isSaving.value) return
const nextValue = (draft.value || props.defaultValue).trim()
if (nextValue === originalValue.value) {
model.value = nextValue
isEditing.value = false
isInvalid.value = false
return
}
if (props.validate && !props.validate(nextValue)) {
isInvalid.value = true
await nextTick()
input.value?.focus()
return
}
isSaving.value = true
isInvalid.value = false
try {
const accepted = await props.onChange?.(nextValue)
if (accepted === false) {
isInvalid.value = true
isSaving.value = false
await nextTick()
input.value?.focus()
return
}
model.value = nextValue
isEditing.value = false
} catch {
isInvalid.value = true
isSaving.value = false
await nextTick()
input.value?.focus()
} finally {
isSaving.value = false
}
}
function cancelEditing() {
if (isSaving.value) return
draft.value = originalValue.value
isInvalid.value = false
isEditing.value = false
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
void applyValue()
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
cancelEditing()
}
}
onUnmounted(clearEditIconHideTimeout)
defineExpose({
isEditing,
startEditing,
})
</script>
<template>
<div
:data-value="sizingValue"
class="relative flex h-6 min-w-3 min-h-0 max-w-full flex-col justify-center border-b font-medium"
:class="
isEditing
? [
'after:invisible after:block after:w-full after:whitespace-pre after:content-[attr(data-value)]',
isInvalid ? 'border-red' : 'border-contrast',
]
: 'border-transparent'
"
:style="{ maxWidth }"
>
<input
v-if="isEditing"
ref="input"
v-model="draft"
type="text"
:aria-invalid="isInvalid"
:aria-label="editLabel"
:disabled="isSaving"
:maxlength="maxLength"
:placeholder="placeholder"
class="absolute inset-0 top-px w-full !h-full !min-h-0 min-w-0 truncate bg-transparent !p-0 text-inherit !border-b-2 border-0 !border-brand !border-solid !shadow-none [font:inherit] !outline-none"
:class="inputClass"
@blur="applyValue"
@click.stop
@keydown="handleKeydown"
/>
<div
v-else-if="activationMode === 'icon'"
class="group/edit-icon flex min-w-0 max-w-full items-center"
@mouseenter="showEditIcon"
@mouseleave="hideEditIcon"
>
<span class="min-w-0 truncate" :class="iconTextClass" :title="model || displayValue">
{{ displayValue }}
</span>
<span
class="flex shrink-0 overflow-hidden transition-all duration-150"
:class="
isEditIconVisible
? 'max-w-6 translate-x-0 opacity-100'
: 'max-w-0 translate-x-1 opacity-0'
"
@mouseenter="keepEditIconVisible"
@mouseleave="handleEditIconMouseleave"
>
<button
type="button"
class="ml-1 flex size-5 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-brand focus-visible:text-contrast"
:aria-label="`${editLabel}: ${displayValue}`"
@click.stop="startEditing"
>
<EditIcon class="size-5" aria-hidden="true" />
</button>
</span>
</div>
<button
v-else-if="activationMode === 'text'"
type="button"
class="flex w-full max-w-full items-center gap-2 truncate border-0 bg-transparent p-0 text-left text-inherit transition-colors hover:text-brand focus-visible:text-contrast [font:inherit]"
:class="buttonClass"
:aria-label="`${editLabel}: ${displayValue}`"
:title="model || displayValue"
@click="startEditing"
>
<span class="min-w-0 truncate">{{ displayValue }}</span>
</button>
<span v-else class="min-w-0 truncate" :title="model || displayValue">
{{ displayValue }}
</span>
</div>
</template>
@@ -83,7 +83,7 @@ defineOptions({
// When clickable is being clicked, give contents an effect
:first-child:active + .smart-clickable__contents:not(.smart-clickable__contents--disabled) {
scale: 0.98;
scale: var(--active-scale, 0.98);
}
}
</style>
@@ -90,7 +90,7 @@
v-if="!multiline && clearable && model && !disabled && !readonly && variant === 'filled'"
type="button"
class="absolute right-0.5 z-[1] p-2 touch-manipulation bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
aria-label="Clear input"
:aria-label="formatMessage(messages.clearInput)"
@click="clear"
>
<XIcon class="h-5 w-5" />
@@ -101,7 +101,7 @@
v-if="!multiline && variant === 'outlined'"
type="button"
class="flex touch-manipulation items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
:aria-label="clearable && model ? 'Clear input' : 'Search'"
:aria-label="formatMessage(clearable && model ? messages.clearInput : messages.search)"
:tabindex="clearable && model ? undefined : -1"
@click="clearable && model ? clear() : undefined"
>
@@ -119,6 +119,14 @@
import { SearchIcon, XIcon } from '@modrinth/assets'
import { type Component, computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
const messages = defineMessages({
clearInput: { id: 'input.clear', defaultMessage: 'Clear input' },
search: { id: 'input.search', defaultMessage: 'Search' },
})
const RING = {
error: 'ring-4 ring-red focus:ring-4 focus:ring-red',
none: 'outline-none focus:ring-4 focus:ring-brand-shadow',
@@ -184,6 +192,10 @@ const resizeClass = computed(
defineExpose({
focus: () => inputRef.value?.focus(),
select: () => {
inputRef.value?.focus()
inputRef.value?.select()
},
setValue: (v: string) => {
if (inputRef.value) inputRef.value.value = v
},
+1
View File
@@ -65,6 +65,7 @@ export { default as HeadingLink } from './HeadingLink.vue'
export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as I18nDebugPanel } from './I18nDebugPanel.vue'
export { default as IconSelect } from './IconSelect.vue'
export { default as InlineEditableText } from './InlineEditableText.vue'
export { default as IntlFormatted } from './IntlFormatted.vue'
export { default as LoadingBar } from './LoadingBar.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'
@@ -1,16 +1,81 @@
<template>
<div class="space-y-6">
<div
v-if="ctx.projectInstall.value"
class="flex items-center gap-2.5 rounded-[20px] bg-surface-2 p-3"
>
<div class="shrink-0">
<div
class="size-14 shrink-0 overflow-hidden rounded-2xl border border-solid border-surface-5"
>
<Avatar
v-if="ctx.projectInstall.value.iconUrl"
:src="ctx.projectInstall.value.iconUrl"
:alt="ctx.projectInstall.value.title"
size="100%"
class="!rounded-2xl"
no-shadow
/>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">
{{ ctx.projectInstall.value.title }}
</span>
<div
v-if="ctx.projectInstall.value.owner"
class="flex items-center gap-2 text-sm text-secondary"
>
<div class="flex items-center gap-1.5 text-inherit">
<Avatar
:src="ctx.projectInstall.value.owner.iconUrl"
:alt="ctx.projectInstall.value.owner.name"
size="1.25rem"
:circle="ctx.projectInstall.value.owner.circle"
no-shadow
/>
<span class="font-medium">{{ ctx.projectInstall.value.owner.name }}</span>
</div>
</div>
</div>
</div>
<!-- Instance-specific: Icon upload -->
<div v-if="ctx.flowType === 'instance'" class="flex items-center gap-4">
<Avatar :src="ctx.instanceIconUrl.value ?? undefined" size="5rem" />
<div class="flex flex-col gap-2">
<div v-if="ctx.flowType === 'instance'" class="flex items-center gap-2.5">
<div class="group relative size-[7.75rem] shrink-0">
<Avatar :src="ctx.instanceIconUrl.value ?? undefined" size="100%" no-shadow />
<div
v-if="ctx.instanceIconUrl.value"
class="pointer-events-none absolute right-1.5 top-1.5 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
>
<Button
size="sm"
class="!p-2"
:aria-label="formatMessage(commonMessages.removeImageButton)"
@click="removeIcon"
>
<XIcon />
</Button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Button type="outlined" @click="triggerIconInput">
<UploadIcon />
{{ formatMessage(messages.selectIcon) }}
{{ formatMessage(messages.uploadIcon) }}
</Button>
<Button type="outlined" :disabled="!ctx.instanceIcon.value" @click="removeIcon">
<XIcon />
{{ formatMessage(messages.removeIcon) }}
<Button
type="outlined"
:disabled="randomizing"
class="disabled:!cursor-defcaault"
@click="randomizeIcon"
>
<SpinnerIcon v-if="randomizing" class="animate-spin" />
<RefreshCwIcon v-else />
{{ formatMessage(messages.randomizeIcon) }}
</Button>
<Button type="outlined" @click="ctx.customizeInstanceIcon?.()">
<PaletteIcon />
{{ formatMessage(messages.customizeIcon) }}
</Button>
</div>
</div>
@@ -145,7 +210,15 @@
<script setup lang="ts">
import type { Paper } from '@modrinth/api-client'
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
import {
EyeIcon,
EyeOffIcon,
PaletteIcon,
RefreshCwIcon,
SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
@@ -177,13 +250,17 @@ const {
} = ctx
const messages = defineMessages({
selectIcon: {
uploadIcon: {
id: 'creation-flow.modal.custom-setup.icon.select',
defaultMessage: 'Select icon',
defaultMessage: 'Upload',
},
removeIcon: {
id: 'creation-flow.modal.custom-setup.icon.remove',
defaultMessage: 'Remove icon',
randomizeIcon: {
id: 'creation-flow.modal.custom-setup.icon.randomize',
defaultMessage: 'Randomize',
},
customizeIcon: {
id: 'creation-flow.modal.custom-setup.icon.customize',
defaultMessage: 'Customize',
},
nameLabel: {
id: 'creation-flow.modal.custom-setup.name.label',
@@ -265,6 +342,9 @@ function formatLoaderVersionTypeLabel(type: LoaderVersionType): string {
// For instance flow, prepend 'vanilla' to available loaders.
// For server flows, vanilla is a separate option in the setup type stage, so exclude it here.
const effectiveLoaders = computed(() => {
if (ctx.projectInstall.value) {
return ctx.projectInstall.value.compatibleLoaders
}
if (ctx.flowType === 'instance') {
return ['vanilla', ...ctx.availableLoaders.filter((l) => l !== 'vanilla')]
}
@@ -277,6 +357,9 @@ const effectiveLoaders = computed(() => {
// Pre-select loader and game version from initial values
onMounted(() => {
debug('mounted, initialLoader:', ctx.initialLoader, 'initialGameVersion:', ctx.initialGameVersion)
if (ctx.flowType === 'instance') {
void randomizeIcon()
}
if (!selectedLoader.value) {
if (ctx.initialLoader) {
selectedLoader.value = ctx.initialLoader
@@ -321,6 +404,23 @@ function removeIcon() {
ctx.instanceIconPath.value = null
}
const randomizing = ref(false)
async function randomizeIcon() {
if (!ctx.randomizeInstanceIcon || randomizing.value) return
randomizing.value = true
try {
const generated = await ctx.randomizeInstanceIcon()
if (!generated) return
ctx.instanceIcon.value = null
ctx.instanceIconUrl.value = generated.previewUrl
ctx.instanceIconPath.value = generated.path
} finally {
randomizing.value = false
}
}
const loaderVersionsLoading = ref(false)
const loaderVersionsData = ref<LoaderVersionEntry[]>([])
@@ -333,6 +433,7 @@ function toApiLoaderName(loader: string): string {
}
const gameVersionsLoading = computed(() => {
if (ctx.projectInstall.value) return false
const loader = selectedLoader.value
if (!loader || loader === 'vanilla') return false
if (loader === 'paper') return ctx.paperSupportedVersions.value === null
@@ -342,6 +443,16 @@ const gameVersionsLoading = computed(() => {
// Game versions from tags provider, filtered by loader support
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
if (ctx.projectInstall.value) {
const versions =
ctx.showSnapshots.value || ctx.projectInstall.value.releaseGameVersions.size === 0
? ctx.projectInstall.value.gameVersions
: ctx.projectInstall.value.gameVersions.filter((version) =>
ctx.projectInstall.value!.releaseGameVersions.has(version),
)
return versions.map((version) => ({ value: version, label: version }))
}
const versions = ctx.showSnapshots.value
? tags.gameVersions.value
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
@@ -506,6 +617,7 @@ function getLoaderVersionsForGameVersion(
watch(
() => selectedLoader.value,
async (loader) => {
if (ctx.projectInstall.value) return
await fetchLoaderMetadata(loader)
},
{ immediate: true },
@@ -521,6 +633,7 @@ watch(
loaderVersionsData.value = []
selectedLoaderVersion.value = null
if (ctx.projectInstall.value) return
if (!loader || !gameVersion || loader === 'vanilla') return
loaderVersionsLoading.value = true
@@ -1,10 +1,53 @@
<template>
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.knownProjectPrompt) }}
</span>
<Combobox
ref="projectSearchCombobox"
v-model="ctx.projectSearchProjectId.value"
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
:options="ctx.projectSearchOptions.value"
searchable
show-search-icon
:show-chevron="false"
:disabled="ctx.finishDisabled.value"
:search-placeholder="formatMessage(messages.searchProjectPlaceholder)"
:no-options-message="
searchLoading
? formatMessage(commonMessages.loadingLabel)
: formatMessage(messages.noResultsFound)
"
:disable-search-filter="true"
@search-input="handleSearch"
>
<template #option-suffix="{ item }">
<div
class="flex shrink-0 items-center gap-1.5 text-sm font-semibold text-secondary opacity-0 transition-opacity group-hover/option:opacity-100 group-data-[focused=true]/option:opacity-100"
>
<span>
{{
formatMessage(
isModpackOption(item.value) ? messages.installModpack : messages.createInstance,
)
}}
</span>
<DownloadIcon v-if="isModpackOption(item.value)" class="size-5 shrink-0" />
<RightArrowIcon v-else class="size-5 shrink-0" />
</div>
</template>
</Combobox>
<div class="flex items-center gap-3">
<div class="h-[1px] w-full flex-1 bg-surface-5" />
<span class="text-sm text-secondary">{{ formatMessage(commonMessages.orLabel) }}</span>
<div class="h-[1px] w-full flex-1 bg-surface-5" />
</div>
<span class="font-semibold text-contrast">
{{ setupTypeTitle }}
</span>
<!-- Instance flow options -->
<template v-if="ctx.flowType === 'instance'">
<div class="flex flex-col gap-3">
<BigOptionButton
@@ -14,10 +57,16 @@
@click="setSetupType('custom')"
/>
<BigOptionButton
:icon="PackageIcon"
:icon="CompassIcon"
:title="formatMessage(messages.modpackBaseTitle)"
:description="formatMessage(messages.modpackBaseDescription)"
@click="setSetupType('modpack')"
@click="browseModpacks"
/>
<BigOptionButton
:icon="UploadIcon"
:title="formatMessage(messages.uploadModpackTitle)"
:description="formatMessage(messages.uploadModpackDescription)"
@click="triggerFileInput"
/>
<BigOptionButton
:icon="BoxImportIcon"
@@ -26,19 +75,21 @@
@click="ctx.setImportMode()"
/>
</div>
<span class="text-sm text-secondary">
{{ formatMessage(messages.instanceDescription) }}
</span>
</template>
<!-- World / Server onboarding flow options -->
<template v-else>
<div class="flex flex-col gap-3">
<BigOptionButton
:icon="PackageIcon"
:icon="CompassIcon"
:title="formatMessage(messages.modpackBaseTitle)"
:description="formatMessage(messages.modpackBaseDescription)"
@click="setSetupType('modpack')"
@click="browseModpacks"
/>
<BigOptionButton
:icon="UploadIcon"
:title="formatMessage(messages.uploadModpackTitle)"
:description="formatMessage(messages.uploadModpackDescription)"
@click="triggerFileInput"
/>
<BigOptionButton
:icon="BoxesIcon"
@@ -58,21 +109,55 @@
</template>
<script setup lang="ts">
import { BoxesIcon, BoxIcon, BoxImportIcon, PackageIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import {
BoxesIcon,
BoxIcon,
BoxImportIcon,
CompassIcon,
DownloadIcon,
RightArrowIcon,
UploadIcon,
} from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker } from '../../../../providers'
import BigOptionButton from '../../../base/BigOptionButton.vue'
import Combobox from '../../../base/Combobox.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('SetupTypeStage')
const ctx = injectCreationFlowContext()
const filePicker = injectFilePicker()
const { setSetupType: _setSetupType } = ctx
const { formatMessage } = useVIntl()
const searchLoading = ref(false)
const projectSearchCombobox = ref<{ $el: HTMLElement }>()
const messages = defineMessages({
knownProjectPrompt: {
id: 'creation-flow.modal.project.known-project.prompt',
defaultMessage: 'Already know what you want to play?',
},
searchProjectPlaceholder: {
id: 'creation-flow.modal.project.search.placeholder',
defaultMessage: 'Search mods, modpacks, and more...',
},
noResultsFound: {
id: 'creation-flow.modal.project.search.no-results',
defaultMessage: 'No results found',
},
installModpack: {
id: 'creation-flow.modal.project.search.install-modpack',
defaultMessage: 'Install modpack',
},
createInstance: {
id: 'creation-flow.modal.project.search.create-instance',
defaultMessage: 'Create instance',
},
instanceTypeTitle: {
id: 'creation-flow.modal.setup-type.title.instance',
defaultMessage: 'Choose instance type',
@@ -95,11 +180,19 @@ const messages = defineMessages({
},
modpackBaseTitle: {
id: 'creation-flow.modal.setup-type.option.modpack-base.title',
defaultMessage: 'Install modpack',
defaultMessage: 'Start from a mod or modpack',
},
modpackBaseDescription: {
id: 'creation-flow.modal.setup-type.option.modpack-base.description',
defaultMessage: 'Browse modpacks on Modrinth or import one from a file.',
defaultMessage: 'Choose a project and well use its latest version.',
},
uploadModpackTitle: {
id: 'creation-flow.modal.setup-type.option.upload-modpack.title',
defaultMessage: 'Upload a modpack',
},
uploadModpackDescription: {
id: 'creation-flow.modal.setup-type.option.upload-modpack.description',
defaultMessage: 'Install a modpack from an .mrpack file on your device.',
},
importInstanceTitle: {
id: 'creation-flow.modal.setup-type.option.import-instance.title',
@@ -109,10 +202,6 @@ const messages = defineMessages({
id: 'creation-flow.modal.setup-type.option.import-instance.description',
defaultMessage: 'Import an instance from Prism, CurseForge, or similar.',
},
instanceDescription: {
id: 'creation-flow.modal.setup-type.instance.description',
defaultMessage: 'An instance is a Minecraft setup with a specific loader, version, and mods.',
},
vanillaMinecraftTitle: {
id: 'creation-flow.modal.setup-type.option.vanilla-minecraft.title',
defaultMessage: 'Vanilla Minecraft',
@@ -133,8 +222,132 @@ const setupTypeTitle = computed(() => {
return formatMessage(messages.worldTypeTitle)
})
function setSetupType(type: 'modpack' | 'custom' | 'vanilla') {
function isModpackOption(projectId: string) {
return ctx.projectSearchHits.value[projectId]?.projectType === 'modpack'
}
function setSetupType(type: 'custom' | 'vanilla') {
debug('selected:', type)
_setSetupType(type)
}
function selectModpack() {
debug('selected: modpack')
_setSetupType('modpack')
}
function proceedWithModpack() {
if (ctx.finishDisabled.value) return
if (ctx.flowType === 'instance') {
ctx.finish()
} else {
ctx.modal.value?.setStage('final-config')
}
}
function browseModpacks() {
if (ctx.finishDisabled.value) return
selectModpack()
ctx.browseModpacks()
}
async function triggerFileInput() {
if (ctx.finishDisabled.value) return
const picked = await filePicker.pickModpackFile({
readFile: ctx.flowType !== 'instance',
})
if (!picked) return
selectModpack()
ctx.modpackFile.value = picked.file ?? null
ctx.modpackFilePath.value = picked.path ?? null
proceedWithModpack()
}
async function search(query: string) {
try {
if (!query.trim()) {
ctx.projectSearchOptions.value = []
return
}
const results = await ctx.searchProjects(query.trim(), 10)
ctx.projectSearchHits.value = {}
for (const hit of results.hits) {
ctx.projectSearchHits.value[hit.project_id] = {
title: hit.title,
iconUrl: hit.icon_url,
latestVersion: hit.latest_version,
projectType: hit.project_type ?? 'modpack',
}
}
ctx.projectSearchOptions.value = results.hits.map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
}))
} catch (error) {
debug('project search failed:', error)
ctx.projectSearchOptions.value = []
} finally {
searchLoading.value = false
}
}
async function handleSearch(query: string) {
searchLoading.value = true
await search(query)
}
onMounted(() => {
ctx.projectSearchProjectId.value = undefined
search('')
setTimeout(() => {
projectSearchCombobox.value?.$el.querySelector('input')?.focus()
}, 150)
})
watch(
() => ctx.projectSearchProjectId.value,
async (projectId, oldProjectId) => {
if (projectId === oldProjectId) return
if (!projectId) return
const hit = ctx.projectSearchHits.value[projectId]
if (ctx.flowType === 'instance') {
void ctx.selectProject(projectId, hit?.projectType ?? 'mod')
return
}
try {
const versions = await ctx.getProjectVersions(projectId)
if (ctx.projectSearchProjectId.value !== projectId || versions.length === 0) return
selectModpack()
ctx.modpackSelection.value = {
projectId,
versionId: versions[0].id,
name: hit?.title ?? '',
iconUrl: hit?.iconUrl,
}
proceedWithModpack()
} catch (error) {
debug('failed to load project versions:', error)
}
},
)
</script>
@@ -12,7 +12,7 @@ import {
} from '#ui/composables/i18n'
import { formatLoaderLabel } from '#ui/utils/loaders'
import { createContext, injectModrinthClient } from '../../../providers'
import { createContext, injectModrinthClient, injectNotificationManager } from '../../../providers'
import type { ImportableLauncher } from '../../../providers/instance-import'
import type { MultiStageModal, StageConfigInput } from '../../base'
import type { ComboboxOption } from '../../base/Combobox.vue'
@@ -81,10 +81,6 @@ export const creationFlowMessages = defineMessages({
id: 'creation-flow.button.import-instances',
defaultMessage: 'Import {count, plural, one {# instance} other {# instances}}',
},
chooseModpackTitle: {
id: 'creation-flow.title.choose-modpack',
defaultMessage: 'Choose modpack',
},
})
export const flowTypeHeadingMessages: Record<FlowType, MessageDescriptor> = {
@@ -101,15 +97,17 @@ export interface ModpackSelection {
iconUrl?: string
}
export interface ModpackSearchHit {
export interface ProjectSearchHit {
title: string
iconUrl?: string
latestVersion?: string
projectType: string
}
export interface ModpackSearchResult {
export interface ProjectSearchResult {
hits: {
project_id: string
project_type?: string
title: string
icon_url: string
latest_version?: string
@@ -119,6 +117,35 @@ export interface ModpackSearchResult {
limit: number
}
export interface ProjectInstallSelection {
projectId: string
title: string
iconUrl?: string | null
link: string
owner?: {
name: string
iconUrl?: string
circle?: boolean
link: string | (() => void)
} | null
compatibleLoaders: string[]
gameVersions: string[]
releaseGameVersions: Set<string>
}
export interface ProjectInstallCreateData {
name: string
iconPath: string | null
iconPreviewUrl: string | null
loader: string
gameVersion: string
}
export interface GeneratedInstanceIcon {
path: string
previewUrl: string
}
export interface CreationFlowContextValue {
// Flow
flowType: FlowType
@@ -152,6 +179,8 @@ export interface CreationFlowContextValue {
instanceIcon: Ref<File | null>
instanceIconUrl: Ref<string | null>
instanceIconPath: Ref<string | null>
randomizeInstanceIcon: (() => Promise<GeneratedInstanceIcon | null>) | null
customizeInstanceIcon: (() => void) | null
// Loader/version state (custom setup)
selectedLoader: Ref<string | null>
@@ -169,13 +198,12 @@ export interface CreationFlowContextValue {
modpackSelection: Ref<ModpackSelection | null>
modpackFile: Ref<File | null>
modpackFilePath: Ref<string | null>
projectInstall: Ref<ProjectInstallSelection | null>
// Modpack search state (persisted across stage navigation)
modpackSearchProjectId: Ref<string | undefined>
modpackSearchVersionId: Ref<string | undefined>
modpackSearchOptions: Ref<ComboboxOption<string>[]>
modpackVersionOptions: Ref<ComboboxOption<string>[]>
modpackSearchHits: Ref<Record<string, ModpackSearchHit>>
// Project search state (persisted across stage navigation)
projectSearchProjectId: Ref<string | undefined>
projectSearchOptions: Ref<ComboboxOption<string>[]>
projectSearchHits: Ref<Record<string, ProjectSearchHit>>
// Import state (instance flow only)
importLaunchers: Ref<ImportableLauncher[]>
@@ -206,13 +234,14 @@ export interface CreationFlowContextValue {
setSetupType: (type: SetupType) => void
setImportMode: () => void
browseModpacks: () => void
selectProject: (projectId: string, projectType: string) => Promise<void>
finish: () => void
buildProperties: () => Archon.Content.v1.PropertiesFields
fetchLoaderMetadata: (loader?: string | null) => Promise<void>
prefetchLoaderMetadata: () => Promise<void>
// Platform-provided search
searchModpacks: (query: string, limit?: number) => Promise<ModpackSearchResult>
searchProjects: (query: string, limit?: number) => Promise<ProjectSearchResult>
getProjectVersions: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest: LoaderManifestResolver | null
}
@@ -232,9 +261,16 @@ export interface CreationFlowOptions {
initialGameVersion?: string
fetchExistingInstanceNames?: () => Promise<string[]>
onBack?: () => void
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
searchProjects?: (query: string, limit?: number) => Promise<ProjectSearchResult>
prepareProjectInstall?: (
projectId: string,
projectType: string,
) => Promise<ProjectInstallSelection | null>
createProjectInstall?: (data: ProjectInstallCreateData) => Promise<void>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
randomizeInstanceIcon?: () => Promise<GeneratedInstanceIcon | null>
customizeInstanceIcon?: () => void
finishDisabled?: ComputedRef<boolean>
finishDisabledTooltip?: ComputedRef<string | undefined>
}
@@ -250,6 +286,7 @@ export function createCreationFlowContext(
): CreationFlowContextValue {
const debug = useDebugLogger('CreationFlow')
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt']
@@ -259,7 +296,11 @@ export function createCreationFlowContext(
const initialLoader = options.initialLoader ?? null
const initialGameVersion = options.initialGameVersion ?? null
const onBack = options.onBack ?? null
const searchModpacks = options.searchModpacks!
const randomizeInstanceIcon = options.randomizeInstanceIcon ?? null
const customizeInstanceIcon = options.customizeInstanceIcon ?? null
const searchProjects = options.searchProjects!
const prepareProjectInstall = options.prepareProjectInstall
const createProjectInstall = options.createProjectInstall
const getProjectVersions = options.getProjectVersions!
const getLoaderManifest = options.getLoaderManifest ?? null
const finishDisabled = options.finishDisabled ?? computed(() => false)
@@ -321,13 +362,12 @@ export function createCreationFlowContext(
const modpackSelection = ref<ModpackSelection | null>(null)
const modpackFile = ref<File | null>(null)
const modpackFilePath = ref<string | null>(null)
const projectInstall = ref<ProjectInstallSelection | null>(null)
// Modpack search state (persisted across stage navigation)
const modpackSearchProjectId = ref<string | undefined>()
const modpackSearchVersionId = ref<string | undefined>()
const modpackSearchOptions = ref<ComboboxOption<string>[]>([])
const modpackVersionOptions = ref<ComboboxOption<string>[]>([])
const modpackSearchHits = ref<Record<string, ModpackSearchHit>>({})
// Project search state (persisted across stage navigation)
const projectSearchProjectId = ref<string | undefined>()
const projectSearchOptions = ref<ComboboxOption<string>[]>([])
const projectSearchHits = ref<Record<string, ProjectSearchHit>>({})
// Import state (instance flow only)
const importLaunchers = ref<ImportableLauncher[]>([])
@@ -344,7 +384,10 @@ export function createCreationFlowContext(
// hideLoaderVersion: hides the loader version section (vanilla world type OR vanilla selected as loader chip)
const hideLoaderVersion = computed(
() => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla',
() =>
setupType.value === 'vanilla' ||
selectedLoader.value === 'vanilla' ||
projectInstall.value !== null,
)
function toApiLoaderName(loader: string): string {
@@ -454,11 +497,10 @@ export function createCreationFlowContext(
modpackSelection.value = null
modpackFile.value = null
modpackFilePath.value = null
modpackSearchProjectId.value = undefined
modpackSearchVersionId.value = undefined
modpackSearchOptions.value = []
modpackVersionOptions.value = []
modpackSearchHits.value = {}
projectInstall.value = null
projectSearchProjectId.value = undefined
projectSearchOptions.value = []
projectSearchHits.value = {}
// Import state
importLaunchers.value = []
@@ -474,12 +516,12 @@ export function createCreationFlowContext(
function setSetupType(type: SetupType) {
debug('setSetupType:', type)
isImportMode.value = false
projectInstall.value = null
setupType.value = type
if (type === 'modpack') {
selectedLoader.value = null
selectedLoaderVersion.value = null
loaderVersionType.value = 'stable'
modal.value?.setStage('modpack')
} else {
modpackSelection.value = null
modpackFile.value = null
@@ -506,6 +548,37 @@ export function createCreationFlowContext(
emit.browseModpacks()
}
async function selectProject(projectId: string, projectType: string) {
if (!prepareProjectInstall) return
try {
const selection = await prepareProjectInstall(projectId, projectType)
if (selection) {
setProjectInstall(selection)
} else {
modal.value?.hide()
}
} catch (error) {
projectSearchProjectId.value = undefined
handleError(error as Error)
}
}
function setProjectInstall(selection: ProjectInstallSelection) {
projectInstall.value = selection
projectSearchProjectId.value = undefined
setupType.value = 'custom'
isImportMode.value = false
modpackSelection.value = null
instanceName.value = selection.title
selectedLoader.value = selection.compatibleLoaders[0] ?? null
selectedGameVersion.value =
selection.gameVersions.find((version) => selection.releaseGameVersions.has(version)) ??
selection.gameVersions[0] ??
null
modal.value?.setStage('custom-setup')
}
function finish() {
if (finishDisabled.value) return
@@ -518,6 +591,22 @@ export function createCreationFlowContext(
hasModpackFile: !!modpackFile.value,
})
loading.value = true
if (
projectInstall.value &&
createProjectInstall &&
selectedLoader.value &&
selectedGameVersion.value
) {
modal.value?.hide()
void createProjectInstall({
name: instanceName.value.trim() || projectInstall.value.title,
iconPath: instanceIconPath.value,
iconPreviewUrl: instanceIconUrl.value,
loader: selectedLoader.value,
gameVersion: selectedGameVersion.value,
}).catch(handleError)
return
}
emit.create(contextValue)
}
@@ -569,6 +658,8 @@ export function createCreationFlowContext(
instanceIcon,
instanceIconUrl,
instanceIconPath,
randomizeInstanceIcon,
customizeInstanceIcon,
selectedLoader,
selectedGameVersion,
loaderVersionType,
@@ -582,11 +673,10 @@ export function createCreationFlowContext(
modpackSelection,
modpackFile,
modpackFilePath,
modpackSearchProjectId,
modpackSearchVersionId,
modpackSearchOptions,
modpackVersionOptions,
modpackSearchHits,
projectInstall,
projectSearchProjectId,
projectSearchOptions,
projectSearchHits,
importLaunchers,
importSelectedInstances,
importSearchQuery,
@@ -603,11 +693,12 @@ export function createCreationFlowContext(
setSetupType,
setImportMode,
browseModpacks,
selectProject,
finish,
buildProperties,
fetchLoaderMetadata,
prefetchLoaderMetadata,
searchModpacks,
searchProjects,
getProjectVersions,
getLoaderManifest,
}
@@ -19,7 +19,9 @@ import {
type CreationFlowContextValue,
type FlowType,
type LoaderManifestResolver,
type ModpackSearchResult,
type ProjectInstallCreateData,
type ProjectInstallSelection,
type ProjectSearchResult,
provideCreationFlowContext,
} from './creation-flow-context'
@@ -35,9 +37,16 @@ const props = withDefaults(
fetchExistingInstanceNames?: () => Promise<string[]>
onBack?: (() => void) | null
fade?: 'standard' | 'warning' | 'danger'
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
searchProjects?: (query: string, limit?: number) => Promise<ProjectSearchResult>
prepareProjectInstall?: (
projectId: string,
projectType: string,
) => Promise<ProjectInstallSelection | null>
createProjectInstall?: (data: ProjectInstallCreateData) => Promise<void>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
randomizeInstanceIcon?: () => Promise<{ path: string; previewUrl: string } | null>
customizeInstanceIcon?: () => void
finishDisabled?: boolean
finishDisabledTooltip?: string
}>(),
@@ -51,6 +60,8 @@ const props = withDefaults(
initialGameVersion: undefined,
fetchExistingInstanceNames: undefined,
onBack: null,
randomizeInstanceIcon: undefined,
customizeInstanceIcon: undefined,
},
)
@@ -77,9 +88,13 @@ const ctx = createCreationFlowContext(
initialGameVersion: props.initialGameVersion,
fetchExistingInstanceNames: props.fetchExistingInstanceNames,
onBack: props.onBack ?? undefined,
searchModpacks: props.searchModpacks,
searchProjects: props.searchProjects,
prepareProjectInstall: props.prepareProjectInstall,
createProjectInstall: props.createProjectInstall,
getProjectVersions: props.getProjectVersions,
getLoaderManifest: props.getLoaderManifest,
randomizeInstanceIcon: props.randomizeInstanceIcon,
customizeInstanceIcon: props.customizeInstanceIcon,
finishDisabled: computed(() => props.finishDisabled ?? false),
finishDisabledTooltip: computed(() => props.finishDisabledTooltip),
},
@@ -72,5 +72,5 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
},
}
},
maxWidth: '520px',
maxWidth: '560px',
}
@@ -64,5 +64,5 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
},
}
},
maxWidth: '520px',
maxWidth: '560px',
}
@@ -43,5 +43,5 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
onClick: () => ctx.finish(),
}
},
maxWidth: '520px',
maxWidth: '560px',
}
@@ -3,12 +3,10 @@ import type { CreationFlowContextValue } from '../creation-flow-context'
import { stageConfig as customSetupStageConfig } from './custom-setup-stage'
import { stageConfig as finalConfigStageConfig } from './final-config-stage'
import { stageConfig as importInstanceStageConfig } from './import-instance-stage'
import { stageConfig as modpackStageConfig } from './modpack-stage'
import { stageConfig as setupTypeStageConfig } from './setup-type-stage'
export const stageConfigs: StageConfigInput<CreationFlowContextValue>[] = [
setupTypeStageConfig,
modpackStageConfig,
importInstanceStageConfig,
customSetupStageConfig,
finalConfigStageConfig,
@@ -1,22 +0,0 @@
import { LeftArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import { commonMessages } from '#ui/utils/common-messages'
import type { StageConfigInput } from '../../../base'
import ModpackStage from '../components/ModpackStage.vue'
import { type CreationFlowContextValue, creationFlowMessages } from '../creation-flow-context'
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'modpack',
title: (ctx) => ctx.formatMessage(creationFlowMessages.chooseModpackTitle),
stageContent: markRaw(ModpackStage),
skip: (ctx) => ctx.setupType.value !== 'modpack' || ctx.isImportMode.value,
leftButtonConfig: (ctx) => ({
label: ctx.formatMessage(commonMessages.backButton),
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('setup-type'),
}),
rightButtonConfig: null,
maxWidth: '520px',
}
@@ -10,5 +10,5 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
stageContent: markRaw(SetupTypeStage),
leftButtonConfig: null,
rightButtonConfig: null,
maxWidth: '520px',
maxWidth: '560px',
}
@@ -16,27 +16,27 @@
@mouseleave="setNotificationTimer(item)"
>
<NotificationToast
v-if="item.toast"
v-if="item.contentType === 'toast'"
class="min-w-full"
:type="item.toast.type"
:type="item.type"
:action-loading="toastActionLoading(item.id)"
:actor-name="item.toast.actorName"
:actor-avatar-url="item.toast.actorAvatarUrl"
:entity-name="item.toast.entityName"
:entity-icon-url="item.toast.entityIconUrl"
:status-text="item.toast.statusText"
:progress="item.toast.progress"
:waiting="item.toast.waiting"
:show-progress="item.toast.showProgress"
:progress-type="item.toast.progressType"
:progress-current="item.toast.progressCurrent"
:progress-total="item.toast.progressTotal"
@accept="handleToastAccept(item, item.toast.onAccept)"
@decline="handleToastAction(item, item.toast.onDecline)"
@dismiss="handleToastAction(item, item.toast.onDismiss)"
@launch="handleToastAction(item, item.toast.onLaunch)"
@open-actor="item.toast.onOpenActor?.()"
@open-instance="handleToastAction(item, item.toast.onOpenInstance)"
:actor-name="item.actorName"
:actor-avatar-url="item.actorAvatarUrl"
:entity-name="item.entityName"
:entity-icon-url="item.entityIconUrl"
:status-text="item.statusText"
:progress="item.progress"
:waiting="item.waiting"
:show-progress="item.showProgress"
:progress-type="item.progressType"
:progress-current="item.progressCurrent"
:progress-total="item.progressTotal"
@accept="handleToastAccept(item, item.onAccept)"
@decline="handleToastAction(item, item.onDecline)"
@dismiss="handleToastAction(item, item.onDismiss)"
@launch="handleToastAction(item, item.onLaunch)"
@open-actor="item.onOpenActor?.()"
@open-instance="handleToastAction(item, item.onOpenInstance)"
/>
<TransitionGroup
v-else-if="isDownloadNotification(item)"
@@ -65,6 +65,12 @@
/>
</div>
</TransitionGroup>
<component
:is="item.component"
v-else-if="item.contentType === 'custom'"
v-bind="item.componentProps"
@dismiss="popupNotificationManager.removeNotification(item.id)"
/>
<div
v-else
class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4"
@@ -105,9 +111,10 @@
<IconButton
v-if="item.dismissible !== false"
type="quiet"
label="Close"
class="-m-1.5"
@click="handleNotificationDismiss(item)"
:label="formatMessage(messages.close)"
size="sm"
class="-m-1.5 -mx-2"
@click="handleStandardNotificationDismiss(item)"
>
<XIcon />
</IconButton>
@@ -115,11 +122,6 @@
<span v-if="item.text" class="text-primary">
{{ item.text }}
</span>
<component
:is="item.bodyComponent"
v-if="item.bodyComponent"
v-bind="item.bodyProps ?? {}"
/>
</div>
<div v-if="item.progressItems?.length" class="flex flex-col gap-3">
<div
@@ -156,20 +158,8 @@
<Button
v-for="(btn, idx) in item.buttons"
:key="idx"
:type="
(btn.color || (idx === 0 ? 'brand' : undefined)) &&
(btn.color || (idx === 0 ? 'brand' : undefined)) !== 'standard'
? 'colored'
: 'base'
"
:color="
(btn.color || (idx === 0 ? 'brand' : undefined)) &&
(btn.color || (idx === 0 ? 'brand' : undefined)) !== 'standard'
? (btn.color || (idx === 0 ? 'brand' : undefined)) === 'medal-promo'
? 'medal_promotion'
: btn.color || (idx === 0 ? 'brand' : undefined)
: undefined
"
:type="notificationButtonColor(btn, idx) ? 'colored' : 'base'"
:color="notificationButtonColor(btn, idx)"
@click="handleButtonClick(item.id, btn)"
>
<component :is="btn.icon" v-if="btn.icon" />
@@ -194,18 +184,25 @@ import {
} from '@modrinth/assets'
import { computed, ref } from 'vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import { Button, type ButtonColor, IconButton } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import {
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationButton,
type PopupNotificationProgressItem,
type PopupNotificationStandard,
} from '../../providers'
import ProgressBar from '../base/ProgressBar.vue'
import NotificationToast from '../notifications/NotificationToast.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
close: { id: 'notifications.close', defaultMessage: 'Close' },
})
const popupNotificationManager = injectPopupNotificationManager()
const notifications = computed<PopupNotification[]>(() =>
popupNotificationManager.getVisibleNotifications(),
@@ -222,14 +219,25 @@ const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n)
const toastActionLoading = (id: string | number) => activeToastActions.value[String(id)] ?? null
function isDownloadNotification(item: PopupNotification) {
return (
function notificationButtonColor(
button: PopupNotificationButton,
index: number,
): ButtonColor | undefined {
const color = button.color ?? (index === 0 ? 'brand' : undefined)
return color === 'standard' ? undefined : color
}
function isDownloadNotification(
item: PopupNotification,
): item is PopupNotificationStandard & { type: 'download' } {
return !!(
item.contentType === 'standard' &&
item.type === 'download' &&
(!!item.progressItems?.length || item.progress != null || item.waiting)
)
}
function downloadToastItems(item: PopupNotification): PopupNotificationProgressItem[] {
function downloadToastItems(item: PopupNotificationStandard): PopupNotificationProgressItem[] {
if (item.progressItems?.length) {
return popupNotificationManager.getVisibleDownloadProgressItems(item)
}
@@ -252,7 +260,7 @@ function handleProgressItemDismiss(
item: PopupNotification,
progressItem: PopupNotificationProgressItem,
) {
popupNotificationManager.hideDownloadItem(item.id, progressItem.id)
popupNotificationManager.hideDownloadItem(item.id, downloadProgressItemId(item, progressItem))
}
async function handleProgressItemAction(
@@ -273,10 +281,17 @@ async function handleProgressItemButtonClick(
) {
await btn.action()
if (!btn.keepOpen) {
popupNotificationManager.hideDownloadItem(item.id, progressItem.id)
popupNotificationManager.hideDownloadItem(item.id, downloadProgressItemId(item, progressItem))
}
}
function downloadProgressItemId(
item: PopupNotification,
progressItem: PopupNotificationProgressItem,
): string | undefined {
return item.contentType === 'standard' && item.progressItems?.length ? progressItem.id : undefined
}
async function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
await btn.action()
if (!btn.keepOpen) {
@@ -284,7 +299,7 @@ async function handleButtonClick(id: string | number, btn: PopupNotificationButt
}
}
async function handleNotificationDismiss(item: PopupNotification) {
async function handleStandardNotificationDismiss(item: PopupNotificationStandard) {
await item.onDismiss?.()
popupNotificationManager.removeNotification(item.id)
}
@@ -314,7 +329,7 @@ async function handleToastAccept(item: PopupNotification, action?: () => void |
}
}
function progressColorForType(type: PopupNotification['type']) {
function progressColorForType(type: PopupNotificationStandard['type']) {
if (type === 'error') {
return 'red'
} else if (type === 'warning') {
@@ -15,47 +15,57 @@
<div class="flex min-w-0 flex-1 flex-col gap-2.5">
<div class="flex w-full items-start gap-1">
<p class="m-0 min-w-0 flex-1 break-words text-lg font-normal leading-6 text-contrast/85">
<template v-if="type === 'friend-request'">
<span class="font-semibold text-contrast">{{ actorLabel }}</span>
<span> sent you a friend request.</span>
</template>
<template v-else>
<button
v-if="actorName"
type="button"
class="m-0 inline border-0 bg-transparent p-0 text-lg font-semibold leading-6 text-contrast hover:underline"
@click="$emit('open-actor')"
>
{{ actorName }}
</button>
<span v-else class="font-semibold text-contrast">Someone</span>
<span class="mx-1">{{ inviteActionText }}</span>
<template v-if="type === 'server-invite'">
<span class="font-semibold text-contrast">{{ entityLabel }}</span
>.
<IntlFormatted
:message-id="
type === 'friend-request'
? messages.friendRequest
: type === 'server-invite'
? messages.serverInvite
: messages.instanceInvite
"
:values="{ actor: actorLabel, entity: entityLabel }"
>
<template #actor="{ children }">
<button
v-if="actorName && type !== 'friend-request'"
type="button"
class="m-0 inline border-0 bg-transparent p-0 text-lg font-semibold leading-6 text-contrast hover:underline"
@click="$emit('open-actor')"
>
<component :is="() => children" />
</button>
<span v-else class="font-semibold text-contrast">
<component :is="() => children" />
</span>
</template>
<template v-else>
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
:tint-by="entityLabel"
size="28px"
no-shadow
raised
class="inline-block !rounded-lg align-middle"
/>
<span class="ml-1 font-semibold text-contrast">{{ entityLabel }}</span>
<span> instance.</span>
<template #entity="{ children }">
<template v-if="type === 'instance-invite'">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
:tint-by="entityLabel"
size="28px"
no-shadow
raised
class="inline-block !rounded-lg align-middle"
/>
</template>
<span
:class="{ 'ml-1': type === 'instance-invite' }"
class="font-semibold text-contrast"
>
<component :is="() => children" />
</span>
</template>
</template>
</IntlFormatted>
</p>
<IconButton
v-if="dismissible"
type="quiet"
size="xs"
label="Dismiss notification"
size="sm"
:label="formatMessage(messages.dismissNotification)"
native-type="button"
class="notification-toast-dismiss !size-6"
class="notification-toast-dismiss -m-1.5"
@click="$emit('dismiss')"
>
<XIcon />
@@ -70,11 +80,11 @@
>
<SpinnerIcon v-if="actionLoading === 'accept'" class="animate-spin" />
<CheckIcon v-else />
Accept
{{ formatMessage(messages.accept) }}
</Button>
<Button type="outlined" :disabled="actionLoading != null" @click="$emit('decline')">
<XIcon />
Decline
{{ formatMessage(messages.decline) }}
</Button>
</div>
</div>
@@ -101,10 +111,10 @@
<IconButton
v-if="dismissible"
type="quiet"
size="xs"
label="Dismiss notification"
size="sm"
:label="formatMessage(messages.dismissNotification)"
native-type="button"
class="notification-toast-dismiss !size-6"
class="notification-toast-dismiss -m-1.5"
@click="$emit('dismiss')"
>
<XIcon />
@@ -133,8 +143,12 @@
class="col-start-1 col-end-3 row-start-3 mt-2 flex min-w-0 items-center justify-between gap-2"
>
<div class="flex min-w-0 items-center gap-2">
<Button type="colored" color="brand" @click="$emit('launch')">Launch game</Button>
<Button type="outlined" @click="$emit('open-instance')">Instance</Button>
<Button type="colored" color="brand" @click="$emit('launch')">
{{ formatMessage(messages.launchGame) }}
</Button>
<Button type="outlined" @click="$emit('open-instance')">
{{ formatMessage(messages.instance) }}
</Button>
</div>
<div v-if="progressLabel" class="notification-inline-progress-label flex-none">
{{ progressLabel }}
@@ -147,20 +161,8 @@
<Button
v-for="(action, index) in actions"
:key="index"
:type="
(action.color || (index === 0 ? 'brand' : undefined)) &&
(action.color || (index === 0 ? 'brand' : undefined)) !== 'standard'
? 'colored'
: 'base'
"
:color="
(action.color || (index === 0 ? 'brand' : undefined)) &&
(action.color || (index === 0 ? 'brand' : undefined)) !== 'standard'
? (action.color || (index === 0 ? 'brand' : undefined)) === 'medal-promo'
? 'medal_promotion'
: action.color || (index === 0 ? 'brand' : undefined)
: undefined
"
:type="notificationButtonColor(action, index) ? 'colored' : 'base'"
:color="notificationButtonColor(action, index)"
@click="$emit('action', index)"
>
<component :is="action.icon" v-if="action.icon" />
@@ -193,12 +195,44 @@
import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import { Button, type ButtonColor, IconButton } from '#ui/components/base/buttons'
import { useFormatBytes, useFormatNumber } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import type { PopupNotificationButton, PopupNotificationProgressType } from '../../providers'
import { truncatedTooltip } from '../../utils/truncate'
import Avatar from '../base/Avatar.vue'
import IntlFormatted from '../base/IntlFormatted.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
friendRequest: {
id: 'notifications.friend-request.body',
defaultMessage: '<actor>{actor}</actor> sent you a friend request.',
},
serverInvite: {
id: 'notifications.server-invite.body',
defaultMessage:
'<actor>{actor}</actor> invited you to manage the server <entity>{entity}</entity>.',
},
instanceInvite: {
id: 'notifications.instance-invite.body',
defaultMessage: '<actor>{actor}</actor> invited you to <entity>{entity}</entity> instance.',
},
dismissNotification: {
id: 'notifications.dismiss',
defaultMessage: 'Dismiss notification',
},
accept: { id: 'notifications.invite.accept', defaultMessage: 'Accept' },
decline: { id: 'notifications.invite.decline', defaultMessage: 'Decline' },
launchGame: { id: 'notifications.instance-ready.launch-game', defaultMessage: 'Launch game' },
instance: { id: 'notifications.instance-ready.open-instance', defaultMessage: 'Instance' },
someone: { id: 'notifications.actor.unknown', defaultMessage: 'Someone' },
installedReady: {
id: 'notifications.instance-ready.status',
defaultMessage: 'Installed and ready to play.',
},
})
type NotificationToastType =
| 'friend-request'
@@ -258,7 +292,15 @@ const isInviteNotification = computed(
props.type === 'instance-invite',
)
const actorLabel = computed(() => props.actorName || 'Someone')
function notificationButtonColor(
button: PopupNotificationButton,
index: number,
): ButtonColor | undefined {
const color = button.color ?? (index === 0 ? 'brand' : undefined)
return color === 'standard' ? undefined : color
}
const actorLabel = computed(() => props.actorName || formatMessage(messages.someone))
const entityLabel = computed(() => props.entityName || '')
const inviteAvatarUrl = computed(() => props.actorAvatarUrl)
const inviteAvatarLabel = computed(() => actorLabel.value)
@@ -269,17 +311,9 @@ const isWaitingProgress = computed(() => props.type === 'instance-download' && p
const formatBytes = useFormatBytes()
const formatNumber = useFormatNumber()
const inviteActionText = computed(() => {
if (props.type === 'server-invite') {
return 'invited you to manage the server'
}
return 'invited you to'
})
const resolvedStatusText = computed(() => {
if (props.type === 'instance-ready') {
return props.statusText ?? 'Installed and ready to play.'
return props.statusText ?? formatMessage(messages.installedReady)
}
return props.statusText ?? ''
@@ -9,7 +9,7 @@
:initial-loader="initialLoader"
:initial-game-version="initialGameVersion"
:fade="props.initialSetup ? undefined : 'danger'"
:search-modpacks="searchModpacks"
:search-projects="searchModpacks"
:get-project-versions="getProjectVersions"
:finish-disabled="!canCompleteSetup"
:finish-disabled-tooltip="!canCompleteSetup ? permissionDeniedMessage : undefined"
@@ -6,7 +6,7 @@ export type {
Gamemode,
GeneratorSettingsMode,
LoaderVersionType,
ModpackSearchResult,
ProjectSearchResult,
SetupType,
} from '../../flows/creation-flow-modal/creation-flow-context'
export { default as CreationFlowModal } from '../../flows/creation-flow-modal/index.vue'
Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

@@ -1,139 +1,74 @@
<template>
<div class="grid grid-cols-2 gap-8 items-center justify-center py-10 max-w-[760px]">
<!-- Left column -->
<div class="flex flex-col gap-8 items-start pr-8 shrink-0">
<!-- Heading -->
<div class="flex flex-col gap-2 items-start w-[300px]">
<p class="text-3xl leading-9 font-semibold text-contrast">
{{ formatMessage(messages.modrinthHostingLabel) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.noServersDescription) }}
</p>
</div>
<!-- Feature list -->
<div class="flex flex-col gap-4 items-start w-full">
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center"
>
<PackageOpenIcon class="size-5 text-secondary" aria-hidden="true" />
<div class="flex w-full max-w-[56rem] h-full flex-col items-stretch justify-between gap-8 py-6">
<div class="flex w-full flex-wrap grow items-center justify-center gap-2">
<div class="mx-auto flex w-full max-w-[20rem] flex-col items-start gap-8">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<h1 class="m-0 text-3xl font-semibold leading-9 text-contrast">
{{ formatMessage(messages.modrinthHostingLabel) }}
</h1>
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">
{{ formatMessage(messages.oneClickModInstallsTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.oneClickModInstallsDescription) }}
</p>
<p class="m-0 text-base text-primary">
{{ formatMessage(messages.noServersDescription) }}
</p>
</div>
<div class="flex w-full flex-col gap-6">
<div v-for="feature in features" :key="feature.key" class="flex items-start gap-4">
<div
class="feature-icon relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[0.875rem] border border-solid bg-surface-1 text-brand"
>
<div class="feature-icon-gradient absolute left-[-1px] top-[-1px] size-[6.25rem]" />
<div class="feature-icon-shade absolute left-[-1px] top-[-1px] size-[6.25rem]" />
<img
:src="iconTexture"
alt=""
class="absolute left-1/2 top-1/2 h-[6.25rem] w-[9.8125rem] max-w-none -translate-x-1/2 -translate-y-1/2 object-cover opacity-40 mix-blend-luminosity"
/>
<component
:is="feature.icon"
class="feature-icon-glyph relative size-5"
aria-hidden="true"
/>
</div>
<div class="flex min-w-0 flex-col gap-0.5">
<p class="m-0 text-lg font-semibold leading-6 text-contrast">
{{ feature.title }}
</p>
<p class="m-0 text-sm leading-5 text-primary">
{{ feature.description }}
</p>
</div>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
>
<GlobeIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">
{{ formatMessage(messages.simpleSetupTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.simpleSetupDescription) }}
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
>
<UsersIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">
{{ formatMessage(messages.playWithFriendsTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.playWithFriendsDescription) }}
</p>
</div>
</div>
</div>
<!-- CTA section -->
<div class="flex flex-col gap-6 items-start">
<div class="flex flex-col gap-3 items-start">
<Button type="colored" color="brand" @click="onClickNewServer?.()">
<div class="flex flex-wrap items-center gap-4">
<Button type="colored" color="brand" size="lg" @click="onClickNewServer?.()">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.newServerButton) }}
</Button>
<AutoLink
to="https://modrinth.com/hosting"
target="_blank"
class="flex items-center gap-1 hover:brightness-125"
class="flex items-center gap-1 hover:brightness-125 font-semibold"
>
{{ formatMessage(messages.learnMoreLink) }}
<RightArrowIcon class="size-5 shrink-0" aria-hidden="true" />
</AutoLink>
</div>
<template v-if="!loggedIn">
<div class="h-px w-full bg-surface-5" />
<div class="flex gap-3 items-center flex-wrap">
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.alreadyHaveServerLabel) }}
</p>
<Button @click="onClickSignIn?.()">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</Button>
</div>
</template>
</div>
<ServerListEmptyPreview />
</div>
<!-- Right column - mod icon grid -->
<div
class="relative flex h-[617px] shrink-0 items-center justify-center overflow-hidden rounded-[40px] pointer-events-none select-none [mask-image:linear-gradient(to_bottom,black_0%,black_35%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,black_0%,black_35%,transparent_100%)]"
>
<div class="rotate-[15deg]">
<div class="flex flex-col gap-4">
<div
v-for="row in GRID_ROWS"
:key="row"
class="flex gap-4 items-center shrink-0"
:class="animated ? (row % 2 === 1 ? 'drift-left' : 'drift-right relative left-14') : ''"
>
<div class="hidden drift-right drift-left"></div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
</div>
</div>
</div>
<div v-if="!loggedIn" class="flex flex-col items-center gap-4 text-center">
<p class="m-0 text-sm text-secondary">
{{ formatMessage(messages.alreadyHaveServerLabel) }}
</p>
<Button @click="onClickSignIn?.()">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</Button>
</div>
</div>
</template>
@@ -147,46 +82,21 @@ import {
RightArrowIcon,
UsersIcon,
} from '@modrinth/assets'
import { AutoLink } from '@modrinth/ui'
import { computed } from 'vue'
import iconTexture from '#ui/assets/welcome/icon-texture.png'
import AutoLink from '#ui/components/base/AutoLink.vue'
import { Button } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import imgAircraft from './grid-images/aircraft.png'
import imgAlexs from "./grid-images/alex's.png"
import imgArtifacts from './grid-images/artifacts.png'
import imgBiomes from './grid-images/biomes.png'
import imgCatac from './grid-images/catac.png'
import imgCobble from './grid-images/cobble.png'
import imgComforts from './grid-images/comforts.png'
import imgCreate from './grid-images/create.png'
import imgCreate1 from './grid-images/create1.png'
import imgCreate2 from './grid-images/create2.png'
import imgCreate3 from './grid-images/create3.png'
import imgCreeper from './grid-images/creeper.png'
import imgFriends from './grid-images/friends.png'
import imgGeo from './grid-images/geo.png'
import imgNaturalist from './grid-images/naturalist.png'
import imgSeasons from './grid-images/seasons.png'
import imgTravellers from './grid-images/travellers.png'
import imgTree from './grid-images/tree.png'
import imgYum1 from './grid-images/yum1.png'
import imgYum2 from './grid-images/yum2.png'
import imgYum3 from './grid-images/yum3.png'
import imgYung from './grid-images/yung.png'
import ServerListEmptyPreview from './ServerListEmptyPreview.vue'
withDefaults(
defineProps<{
animated?: boolean
onClickNewServer?: () => void
onClickSignIn?: () => void
loggedIn?: boolean
}>(),
{ animated: false },
)
defineProps<{
onClickNewServer?: () => void
onClickSignIn?: () => void
loggedIn?: boolean
}>()
const GRID_ROWS = 6
const GRID_COLS = 5
const { formatMessage } = useVIntl()
const messages = defineMessages({
@@ -194,10 +104,6 @@ const messages = defineMessages({
id: 'servers.list-empty.modrinth-hosting-label',
defaultMessage: 'Modrinth Hosting',
},
noServersTitle: {
id: 'servers.list-empty.no-servers-title',
defaultMessage: 'No servers yet',
},
noServersDescription: {
id: 'servers.list-empty.no-servers-description',
defaultMessage: 'Install mods, invite friends, and play together all from the Modrinth App.',
@@ -224,7 +130,7 @@ const messages = defineMessages({
},
playWithFriendsDescription: {
id: 'servers.list-empty.play-with-friends-description',
defaultMessage: 'Invite friends and get them set up right in the Modrinth App.',
defaultMessage: 'Invite friends to play your server with one-click play from the Modrinth App.',
},
newServerButton: {
id: 'servers.list-empty.new-server-button',
@@ -232,7 +138,7 @@ const messages = defineMessages({
},
learnMoreLink: {
id: 'servers.list-empty.learn-more-link',
defaultMessage: 'Learn more about Modrinth Hosting',
defaultMessage: 'Learn more',
},
alreadyHaveServerLabel: {
id: 'servers.list-empty.already-have-server-label',
@@ -240,70 +146,55 @@ const messages = defineMessages({
},
signInButton: {
id: 'servers.list-empty.sign-in-button',
defaultMessage: 'Sign in',
defaultMessage: 'Sign in to Modrinth',
},
})
const GRID_IMAGES = [
imgYum1,
imgYum2,
imgYum3,
imgYung,
imgCreeper,
imgFriends,
imgNaturalist,
imgBiomes,
imgCatac,
imgCobble,
imgGeo,
imgCreate,
imgCreate1,
imgCreate2,
imgCreate3,
imgAircraft,
imgArtifacts,
imgComforts,
imgTravellers,
imgAlexs,
imgSeasons,
imgTree,
]
function getGridImage(row: number, col: number): string {
return GRID_IMAGES[(row * GRID_COLS + col) % GRID_IMAGES.length]
}
const features = computed(() => [
{
key: 'one-click-mod-installs',
icon: PackageOpenIcon,
title: formatMessage(messages.oneClickModInstallsTitle),
description: formatMessage(messages.oneClickModInstallsDescription),
},
{
key: 'simple-setup',
icon: GlobeIcon,
title: formatMessage(messages.simpleSetupTitle),
description: formatMessage(messages.simpleSetupDescription),
},
{
key: 'play-with-friends',
icon: UsersIcon,
title: formatMessage(messages.playWithFriendsTitle),
description: formatMessage(messages.playWithFriendsDescription),
},
])
</script>
<style scoped>
p {
margin: 0;
.feature-icon {
border-color: color-mix(in srgb, var(--color-text-primary) 10%, transparent);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--color-brand) 30%, var(--surface-1)),
var(--shadow-card),
0 0 3.75rem color-mix(in srgb, var(--color-brand) 10%, transparent);
}
@keyframes drift-right {
from {
transform: translateX(-33%);
}
to {
transform: translateX(33%);
}
.feature-icon-gradient {
background: linear-gradient(180deg, var(--color-green-800) 0%, var(--color-green-950) 100%);
opacity: 0.5;
}
@keyframes drift-left {
from {
transform: translateX(33%);
}
to {
transform: translateX(-33%);
}
.feature-icon-shade {
background: linear-gradient(
-14deg,
color-mix(in srgb, var(--color-green-950) 37%, transparent) 8%,
transparent 86%
);
}
.drift-left {
animation: drift-left linear infinite alternate;
animation-duration: 400s;
}
.drift-right {
animation: drift-right linear infinite alternate;
animation-duration: 400s;
.feature-icon-glyph {
filter: drop-shadow(0 1px 1px color-mix(in srgb, var(--color-green-950) 12%, transparent));
}
</style>
@@ -0,0 +1,245 @@
<template>
<div
inert
aria-hidden="true"
class="relative mx-auto h-[38rem] w-full max-w-[25rem] select-none -mb-10"
>
<div
class="absolute inset-x-0 top-0 h-full overflow-hidden rounded-2xl border border-solid border-surface-3 bg-surface-2 shadow-card"
>
<div
class="flex items-center justify-between border-0 border-b border-solid border-surface-3 p-4"
>
<div class="flex items-center gap-2 text-contrast">
<UserPlusIcon class="size-5 text-primary" />
<span class="text-lg font-semibold">{{ formatMessage(messages.heading) }}</span>
</div>
<XIcon class="size-5 text-secondary" />
</div>
<div class="flex gap-2 border-0 border-b border-solid border-surface-3 p-4">
<div
class="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-xl bg-surface-3 px-3 text-sm text-secondary"
>
<SearchIcon class="size-4 shrink-0" />
<span class="truncate text-secondary">{{
formatMessage(messages.searchPlaceholder)
}}</span>
</div>
<Button type="colored" color="brand" class="!cursor-default" disabled>
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.addButton) }}
</Button>
</div>
<div class="bg-surface-1 py-3">
<p class="m-0 pb-2 text-sm font-semibold text-primary px-4">
{{ formatMessage(messages.friendsHeading, { count: totalFriendCount }) }}
</p>
<div
v-for="(friend, index) in friends"
:key="friend.username"
class="relative flex h-11 items-center justify-between gap-3 px-4"
:class="[index > 4 ? 'opacity-40' : '', friend.showPointer ? 'bg-surface-2' : '']"
>
<div class="flex min-w-0 items-center gap-2">
<div class="relative flex shrink-0">
<Avatar :src="friend.avatarUrl" size="1.5rem" class="!border-none" circle no-shadow />
<span
v-if="friend.presence"
class="absolute bottom-0 right-[-1px] size-2 rounded-full border border-solid border-surface-1"
:class="friend.presence === 'online' ? 'bg-brand' : 'bg-blue'"
/>
</div>
<span class="truncate text-base font-medium text-primary">{{ friend.username }}</span>
</div>
<Button
:type="friend.status === 'cancel' ? 'outlined' : 'base'"
class="!cursor-default !font-medium !h-8"
size="md"
:disabled="friend.status === 'added'"
:class="friend.status === 'added' ? '' : 'w-20'"
>
<CheckIcon v-if="friend.status === 'added'" aria-hidden="true" />
{{ friendStatusLabel(friend.status) }}
</Button>
<div
v-if="friend.showPointer"
class="absolute right-[14.25rem] top-9 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 opacity-75"
>
<img :src="pointerIcon" alt="" class="size-4" />
</div>
</div>
</div>
<div
class="absolute inset-x-0 bottom-0 border-0 border-t border-solid border-surface-3 bg-surface-2 p-4"
>
<p class="m-0 pb-2 text-base font-semibold text-contrast">
{{ formatMessage(messages.inviteLinkHeading) }}
</p>
<div
class="flex h-8 items-center justify-between gap-2 rounded-xl bg-surface-1 px-2.5 text-sm font-medium text-primary"
>
<span class="truncate">https://modrinth.com/server/abc123</span>
<ClipboardCopyIcon class="size-4 shrink-0 text-secondary" />
</div>
</div>
</div>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-[28rem] [background:linear-gradient(to_bottom,transparent,var(--surface-1))]"
/>
<div
class="absolute left-[32%] top-[23rem] z-10 flex w-[21rem] max-w-[calc(100%-1rem)] gap-4 !overflow-hidden rounded-2xl border border-solid border-surface-4 bg-surface-2 px-4 py-3 shadow-card"
>
<div
class="absolute top-[16px] left-[16px] size-[20px] rounded-full bg-contrast opacity-75 blur-xl"
/>
<div class="relative h-max">
<Avatar :src="geometricallyAvatar" size="2.25rem" circle no-shadow />
<span
class="absolute bottom-0 right-[-1px] size-3 rounded-full border-2 border-solid border-surface-2 bg-brand"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-1">
<div class="min-w-0 flex-1 text-base leading-5 text-primary">
<p class="m-0">
<span class="font-medium text-contrast">Geometrically</span>
{{ formatMessage(messages.invitedYouTo) }}
</p>
<p class="m-0 flex items-center gap-1">
<Avatar :src="modrinthSmpIcon" size="1.25rem" no-shadow />
<span class="font-medium text-contrast">Modrinth SMP</span>
<span>{{ formatMessage(messages.serverSuffix) }}</span>
</p>
</div>
<XIcon class="size-5 shrink-0 text-secondary" />
</div>
<div class="mt-2.5 flex gap-2">
<Button type="colored" color="brand" class="!cursor-default !font-medium">
{{ formatMessage(messages.acceptButton) }}
</Button>
<Button type="outlined" class="!cursor-default !font-medium">
{{ formatMessage(messages.declineButton) }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
CheckIcon,
ClipboardCopyIcon,
PlusIcon,
SearchIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import borisAvatar from '#ui/assets/servers/server-list-empty/boris.png'
import fetchAvatar from '#ui/assets/servers/server-list-empty/fetch.png'
import geometricallyAvatar from '#ui/assets/servers/server-list-empty/geometrically.png'
import imb11Avatar from '#ui/assets/servers/server-list-empty/imb11.png'
import joshAvatar from '#ui/assets/servers/server-list-empty/josh.png'
import michaelAvatar from '#ui/assets/servers/server-list-empty/michael.png'
import modrinthSmpIcon from '#ui/assets/servers/server-list-empty/modrinth-smp.png'
import prospectorAvatar from '#ui/assets/servers/server-list-empty/prospector.png'
import sayaAvatar from '#ui/assets/servers/server-list-empty/saya.png'
import trumanAvatar from '#ui/assets/servers/server-list-empty/truman.png'
import Avatar from '#ui/components/base/Avatar.vue'
import Button from '#ui/components/base/buttons/Button.vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import pointerIcon from './Pointer.png'
type FriendStatus = 'added' | 'cancel' | 'invite'
type FriendPresence = 'online' | 'playing'
const { formatMessage } = useVIntl()
const messages = defineMessages({
heading: {
id: 'sharing.invite-players-modal.heading',
defaultMessage: 'Invite players',
},
searchPlaceholder: {
id: 'sharing.invite-players-modal.search-placeholder',
defaultMessage: 'Enter Modrinth username',
},
addButton: {
id: 'sharing.invite-players-modal.add',
defaultMessage: 'Add',
},
friendsHeading: {
id: 'sharing.invite-players-modal.friends-heading',
defaultMessage: 'Your friends - {count}',
},
addedButton: {
id: 'sharing.invite-players-modal.added',
defaultMessage: 'Added',
},
cancelButton: {
id: 'sharing.invite-players-modal.cancel',
defaultMessage: 'Cancel',
},
inviteButton: {
id: 'sharing.invite-players-modal.invite',
defaultMessage: 'Invite',
},
inviteLinkHeading: {
id: 'sharing.invite-players-modal.invite-link-heading',
defaultMessage: 'Or use an invite link',
},
invitedYouTo: {
id: 'sharing.invite-players-toast.invited-you-to',
defaultMessage: 'invited you to',
},
serverSuffix: {
id: 'sharing.invite-players-toast.server-suffix',
defaultMessage: 'server.',
},
acceptButton: {
id: 'sharing.invite-players-toast.accept',
defaultMessage: 'Accept',
},
declineButton: {
id: 'sharing.invite-players-toast.decline',
defaultMessage: 'Decline',
},
})
const friends: Array<{
username: string
avatarUrl: string
status: FriendStatus
presence?: FriendPresence
showPointer?: boolean
}> = [
{ username: 'Josh', avatarUrl: joshAvatar, status: 'added' },
{
username: 'Prospector',
avatarUrl: prospectorAvatar,
status: 'invite',
presence: 'online',
showPointer: true,
},
{ username: 'Fetch', avatarUrl: fetchAvatar, status: 'cancel', presence: 'playing' },
{ username: 'IMB11', avatarUrl: imb11Avatar, status: 'invite' },
{ username: 'Truman', avatarUrl: trumanAvatar, status: 'invite' },
{ username: 'Boris', avatarUrl: borisAvatar, status: 'invite' },
{ username: 'Saya', avatarUrl: sayaAvatar, status: 'invite' },
{ username: 'Michael', avatarUrl: michaelAvatar, status: 'invite' },
]
const totalFriendCount = 11
function friendStatusLabel(status: FriendStatus): string {
if (status === 'added') return formatMessage(messages.addedButton)
if (status === 'cancel') return formatMessage(messages.cancelButton)
return formatMessage(messages.inviteButton)
}
</script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+40 -21
View File
@@ -1,6 +1,15 @@
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import { ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
const messages = defineMessages({
selectNamedSkin: { id: 'skins.select-named', defaultMessage: 'Select {name}' },
selectSkin: { id: 'skins.select', defaultMessage: 'Select skin' },
})
const emit = defineEmits<{
(e: 'select'): void
(e: 'edit', event: MouseEvent): void
@@ -63,7 +72,11 @@ watch(
<button
class="absolute inset-0 z-10 cursor-pointer border-none bg-transparent p-0 focus-visible:outline-none"
:aria-label="tooltip ? `Select ${tooltip}` : 'Select skin'"
:aria-label="
tooltip
? formatMessage(messages.selectNamedSkin, { name: tooltip })
: formatMessage(messages.selectSkin)
"
:aria-pressed="selected"
:disabled="disabled"
@click="emit('select')"
@@ -74,6 +87,14 @@ watch(
class="pointer-events-none absolute right-3 top-3 z-20 size-3 rounded-full border-2 border-solid border-surface-3 bg-green"
></span>
<span
v-if="selected"
class="pointer-events-none absolute right-3 top-3 z-20 flex size-6 items-center justify-center rounded-full"
>
<span class="absolute inset-0 rounded-full bg-contrast"></span>
<CheckIcon class="relative size-4 invert [stroke-width:3]" />
</span>
<div v-if="!imagesLoaded.forward" class="skeleton-loader h-full w-full">
<div class="skeleton absolute inset-0 aspect-[5/7]"></div>
</div>
@@ -109,22 +130,23 @@ watch(
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg) 25%,
var(--color-raised-bg) 50%,
var(--color-bg) 75%
);
background-size: 200% 100%;
animation: wave 1500ms infinite linear;
background: linear-gradient(145deg, var(--surface-2), var(--surface-3));
animation: skeleton-pulse 2700ms ease-in-out infinite;
}
@keyframes wave {
0% {
background-position: -200% 0;
}
@keyframes skeleton-pulse {
0%,
100% {
background-position: 200% 0;
filter: brightness(0.95);
}
50% {
filter: brightness(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
@@ -168,13 +190,10 @@ watch(
.skin-button.skin-button--selected:hover,
.skin-button.skin-button--selected:focus-within,
.skin-button.skin-button--selected.skin-button--with-actions:hover,
.skin-button.skin-button--selected.skin-button--with-actions:focus-within,
.skin-button.skin-button--active:hover,
.skin-button.skin-button--active:focus-within,
.skin-button.skin-button--active.skin-button--with-actions:hover,
.skin-button.skin-button--active.skin-button--with-actions:focus-within {
border-color: var(--color-brand);
background: var(--color-brand-highlight);
.skin-button.skin-button--selected.skin-button--with-actions:focus-within {
border-color: color-mix(in srgb, var(--color-text-primary) 40%, transparent);
background: var(--surface-3);
filter: brightness(1.1);
}
.skin-button--disabled {