Files
Starlight_Lancher/apps/app-frontend/src/components/ui/modal/InstanceIconPickerModal.vue
T
Disy d2a319a4d5
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
fix: 启用启动器更新并修复初始化竞态
2026-09-26 13:06:51 +08:00

375 lines
12 KiB
Vue

<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:on-hide="handleHide"
width="min(760px, calc(100vw - 2rem))"
max-width="760px"
scrollable
>
<div class="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)]">
<div class="flex flex-col items-center gap-4">
<div
class="flex aspect-square w-44 items-center justify-center overflow-hidden rounded-3xl shadow-lg"
:style="selectedBackground.style"
>
<img :src="selectedIcon.url" alt="" class="h-[72%] w-[72%] object-contain" />
</div>
<p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.description) }}
</p>
<ButtonStyled type="outlined">
<button :disabled="saving" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</button>
</ButtonStyled>
</div>
<div class="flex min-w-0 flex-col gap-5">
<section class="flex flex-col gap-2.5">
<h2 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.background) }}
</h2>
<div class="flex flex-wrap gap-2">
<button
v-for="background in backgrounds"
:key="background.id"
type="button"
class="h-10 w-10 cursor-pointer rounded-xl border-2 border-solid transition-transform hover:scale-105 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
:class="
background.id === selectedBackgroundId
? 'border-contrast shadow-md'
: 'border-transparent'
"
:style="background.style"
:aria-label="formatMessage(background.name)"
:aria-pressed="background.id === selectedBackgroundId"
:disabled="saving"
@click="selectedBackgroundId = background.id"
/>
</div>
</section>
<section v-for="group in iconGroups" :key="group.id" class="flex min-w-0 flex-col gap-2.5">
<h2 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(group.name) }}
</h2>
<div class="grid grid-cols-4 gap-2 sm:grid-cols-6">
<button
v-for="icon in group.icons"
:key="icon.id"
type="button"
class="group flex min-w-0 cursor-pointer flex-col items-center gap-1.5 rounded-xl border border-solid bg-surface-2 p-2 text-secondary transition-colors hover:border-brand hover:bg-brand-highlight hover:text-contrast focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
:class="
icon.id === selectedIconId
? 'border-brand bg-brand-highlight text-contrast'
: 'border-surface-5'
"
:aria-label="formatMessage(icon.name)"
:aria-pressed="icon.id === selectedIconId"
:disabled="saving"
@click="selectedIconId = icon.id"
>
<img :src="icon.url" alt="" class="aspect-square w-full object-contain" />
<span class="w-full truncate text-center text-xs font-semibold">
{{ formatMessage(icon.name) }}
</span>
</button>
</div>
</section>
</div>
</div>
<template #actions>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled type="outlined">
<button :disabled="saving" @click="selectUploadedIcon">
<UploadIcon />
{{ formatMessage(messages.upload) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="saving" @click="saveGeneratedIcon">
<SpinnerIcon v-if="saving" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(saving ? messages.saving : messages.useIcon) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { RefreshCwIcon, SaveIcon, SpinnerIcon, UploadIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessage,
defineMessages,
injectNotificationManager,
type MessageDescriptor,
NewModal,
type PickedFile,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref, useTemplateRef } from 'vue'
import { cache_icon } from '@/helpers/instance'
import { builtInInstanceIcons, modrinth3DInstanceIcons } from '@/helpers/instance-icons'
import { pickImage } from '@/providers/setup/file-picker'
interface IconBackground {
id: string
name: MessageDescriptor
colors: [string, string] | null
style: Record<string, string>
}
function makeBackground(
id: string,
name: MessageDescriptor,
top: string,
bottom: string,
): IconBackground {
return {
id,
name,
colors: [top, bottom],
style: { backgroundImage: `linear-gradient(145deg, ${top}, ${bottom})` },
}
}
const backgroundNames = defineMessages({
transparent: {
id: 'app.instance.icon-picker.background.transparent',
defaultMessage: 'Transparent',
},
grass: { id: 'app.instance.icon-picker.background.grass', defaultMessage: 'Grass' },
ocean: { id: 'app.instance.icon-picker.background.ocean', defaultMessage: 'Ocean' },
amethyst: { id: 'app.instance.icon-picker.background.amethyst', defaultMessage: 'Amethyst' },
sunset: { id: 'app.instance.icon-picker.background.sunset', defaultMessage: 'Sunset' },
cherry: { id: 'app.instance.icon-picker.background.cherry', defaultMessage: 'Cherry' },
nether: { id: 'app.instance.icon-picker.background.nether', defaultMessage: 'Nether' },
slime: { id: 'app.instance.icon-picker.background.slime', defaultMessage: 'Slime' },
deepDark: { id: 'app.instance.icon-picker.background.deep-dark', defaultMessage: 'Deep Dark' },
stone: { id: 'app.instance.icon-picker.background.stone', defaultMessage: 'Stone' },
midnight: { id: 'app.instance.icon-picker.background.midnight', defaultMessage: 'Midnight' },
})
const backgrounds = [
{
id: 'transparent',
name: backgroundNames.transparent,
colors: null,
style: {
backgroundColor: '#ffffff',
backgroundImage:
'linear-gradient(45deg, #d7d9de 25%, transparent 25%), linear-gradient(-45deg, #d7d9de 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d7d9de 75%), linear-gradient(-45deg, transparent 75%, #d7d9de 75%)',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0',
backgroundSize: '16px 16px',
},
} satisfies IconBackground,
makeBackground('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
makeBackground('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
makeBackground('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
makeBackground('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
makeBackground('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
makeBackground('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
makeBackground('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
makeBackground('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
makeBackground('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
makeBackground('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
]
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const iconOptions = [...builtInInstanceIcons, ...modrinth3DInstanceIcons]
const iconGroups = [
{
id: 'original',
name: defineMessage({
id: 'app.instance.icon-picker.group.original',
defaultMessage: 'Original icons',
}),
icons: builtInInstanceIcons,
},
{
id: 'modrinth-3d',
name: defineMessage({
id: 'app.instance.icon-picker.group.modrinth-3d',
defaultMessage: 'Modrinth 3D icons',
}),
icons: modrinth3DInstanceIcons,
},
]
const selectedBackgroundId = ref(backgrounds[0].id)
const selectedIconId = ref(builtInInstanceIcons[0].id)
const saving = ref(false)
let resolveSelection: ((selection: PickedFile | null) => void) | null = null
const selectedBackground = computed(
() => backgrounds.find((item) => item.id === selectedBackgroundId.value) ?? backgrounds[0],
)
const selectedIcon = computed(
() => iconOptions.find((icon) => icon.id === selectedIconId.value) ?? builtInInstanceIcons[0],
)
const messages = defineMessages({
title: {
id: 'app.instance.icon-picker.title',
defaultMessage: 'Create an instance icon',
},
description: {
id: 'app.instance.icon-picker.description',
defaultMessage: 'Combine a background and a Minecraft element, or upload your own image.',
},
background: {
id: 'app.instance.icon-picker.background',
defaultMessage: 'Background',
},
surpriseMe: {
id: 'app.instance.icon-picker.surprise-me',
defaultMessage: 'Surprise me',
},
upload: {
id: 'app.instance.icon-picker.upload',
defaultMessage: 'Upload image',
},
useIcon: {
id: 'app.instance.icon-picker.use-icon',
defaultMessage: 'Use this icon',
},
saving: {
id: 'app.instance.icon-picker.saving',
defaultMessage: 'Saving...',
},
loadError: {
id: 'app.instance.icon-picker.load-error',
defaultMessage: 'Failed to load the bundled icon.',
},
})
function finish(selection: PickedFile | null) {
const resolve = resolveSelection
resolveSelection = null
modal.value?.hide()
resolve?.(selection)
}
function handleHide() {
saving.value = false
if (resolveSelection) {
resolveSelection(null)
resolveSelection = null
}
}
function surpriseMe() {
if (backgrounds.length > 1) {
const candidates = backgrounds.filter((item) => item.id !== selectedBackgroundId.value)
selectedBackgroundId.value = candidates[Math.floor(Math.random() * candidates.length)].id
}
if (iconOptions.length > 1) {
const candidates = iconOptions.filter((icon) => icon.id !== selectedIconId.value)
selectedIconId.value = candidates[Math.floor(Math.random() * candidates.length)].id
}
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error(formatMessage(messages.loadError)))
image.src = url
})
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob)
else reject(new Error(formatMessage(messages.loadError)))
}, 'image/png')
})
}
async function renderGeneratedIcon(): Promise<Blob> {
if (!selectedBackground.value.colors) {
const response = await fetch(selectedIcon.value.url)
if (!response.ok) throw new Error(formatMessage(messages.loadError))
return await response.blob()
}
const size = 512
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
if (!context) throw new Error(formatMessage(messages.loadError))
if (selectedBackground.value.colors) {
const gradient = context.createLinearGradient(0, 0, size, size)
gradient.addColorStop(0, selectedBackground.value.colors[0])
gradient.addColorStop(1, selectedBackground.value.colors[1])
context.fillStyle = gradient
context.fillRect(0, 0, size, size)
}
const symbol = await loadImage(selectedIcon.value.url)
const maxSymbolSize = size * 0.72
const scale = Math.min(maxSymbolSize / symbol.naturalWidth, maxSymbolSize / symbol.naturalHeight)
const width = symbol.naturalWidth * scale
const height = symbol.naturalHeight * scale
context.drawImage(symbol, (size - width) / 2, (size - height) / 2, width, height)
return await canvasToBlob(canvas)
}
async function saveGeneratedIcon() {
if (saving.value) return
saving.value = true
try {
const blob = await renderGeneratedIcon()
const fileName = `generated-${selectedBackgroundId.value}-${selectedIconId.value}.png`
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()))
const path = await cache_icon(fileName, bytes)
finish({
file: new File([blob], fileName, { type: 'image/png' }),
path,
previewUrl: convertFileSrc(path),
frameless: selectedBackgroundId.value === 'transparent',
})
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function selectUploadedIcon() {
try {
const selection = await pickImage()
if (selection) finish(selection)
} catch (error) {
handleError(error)
}
}
function show(): Promise<PickedFile | null> {
resolveSelection?.(null)
const modalInstance = modal.value
if (!modalInstance) return Promise.resolve(null)
return new Promise((resolve) => {
resolveSelection = resolve
modalInstance.show()
})
}
defineExpose({ show })
</script>