feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,210 @@
import { type Ref, shallowRef, triggerRef } from 'vue'
import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
const ARCHIVE_CAPACITY = 20_000
const ARCHIVE_TEXT_CAPACITY = 4 * 1024 * 1024
const MAX_LOG_LINE_LENGTH = 64 * 1024
const BATCH_TIMEOUT_MS = 300
const INITIAL_BATCH_SIZE = 256
const ENTRY_START_RE = /^\[\d{2}:\d{2}:\d{2}\]/
const LOG_TRUNCATION_MARKER = ' … [log output truncated by Axolotl] … '
export interface ConsoleState {
output: Ref<LogLine[]>
addLog4jEvent: (event: Log4jEvent) => void
addLegacyLog: (message: string) => Promise<void>
clear: () => void
}
function groupContinuations(lines: LogLine[]): LogLine[] {
if (lines.length <= 1) return lines
const groups: LogLine[][] = []
for (const line of lines) {
if (ENTRY_START_RE.test(line.text)) {
groups.push([line])
} else if (groups.length > 0) {
let target = groups.length - 1
const lastEntry = groups[target][0]
if (lastEntry.level !== 'error' && lastEntry.level !== 'warn') {
if (line.level === 'error' || line.level === null) {
for (let i = groups.length - 2; i >= 0; i--) {
if (groups[i][0].level === 'error' || groups[i][0].level === 'warn') {
target = i
break
}
}
}
}
groups[target].push(line)
} else {
groups.push([line])
}
}
return groups.flat()
}
function mapLog4jLevel(level?: string): LogLevel | null {
if (!level) return null
switch (level.toUpperCase()) {
case 'FATAL':
case 'ERROR':
return 'error'
case 'WARN':
return 'warn'
case 'INFO':
return 'info'
case 'DEBUG':
return 'debug'
case 'TRACE':
return 'trace'
default:
return null
}
}
function formatTimestamp(millis?: number): string {
if (!millis) return ''
const date = new Date(millis)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `[${hours}:${minutes}:${seconds}]`
}
function truncateLogText(text: string, maximumLength = MAX_LOG_LINE_LENGTH): string {
if (text.length <= maximumLength) return text
const retainedLength = Math.max(0, maximumLength - LOG_TRUNCATION_MARKER.length)
const prefixLength = Math.floor(retainedLength / 2)
const suffixLength = retainedLength - prefixLength
return `${text.slice(0, prefixLength)}${LOG_TRUNCATION_MARKER}${text.slice(-suffixLength)}`
}
function formatLog4jLines(event: Log4jEvent): LogLine[] {
const level = mapLog4jLevel(event.level)
const time = formatTimestamp(event.timestamp_millis)
const thread = event.thread_name ?? ''
const levelText = event.level ?? ''
const message = truncateLogText(event.message?.trim() ?? '')
const prefix = time ? `${time} [${thread}/${levelText}]: ` : `[${thread}/${levelText}]: `
const messageLines = message.split(/[\r\n]+/)
const lines: LogLine[] = [{ text: prefix + messageLines[0], level }]
for (let i = 1; i < messageLines.length; i++) {
if (!messageLines[i]) continue
lines.push({ text: messageLines[i], level })
}
if (event.throwable) {
for (const line of truncateLogText(event.throwable).split(/[\r\n]+/)) {
if (!line) continue
lines.push({ text: line, level: 'error' })
}
}
return lines
}
function textToLogLine(text: string): LogLine {
const truncated = truncateLogText(text)
return { text: truncated, level: detectLogLevel(truncated) }
}
export function createConsoleState(): ConsoleState {
const output = shallowRef<LogLine[]>([])
let lineBuffer: LogLine[] = []
let batchTimer: ReturnType<typeof setTimeout> | null = null
let outputTextLength = 0
function appendLines(lines: LogLine[]) {
for (const line of lines) {
const text = truncateLogText(line.text)
output.value.push(text === line.text ? line : { ...line, text })
outputTextLength += text.length
}
while (output.value.length > ARCHIVE_CAPACITY || outputTextLength > ARCHIVE_TEXT_CAPACITY) {
const removed = output.value.shift()
if (!removed) break
outputTextLength -= removed.text.length
}
}
function flushBuffer() {
if (lineBuffer.length === 0) return
const lines = groupContinuations(lineBuffer)
lineBuffer = []
batchTimer = null
appendLines(lines)
triggerRef(output)
}
function addLines(lines: LogLine[]) {
if (lines.length === 0) return
if (output.value.length === 0 && lines.length >= INITIAL_BATCH_SIZE) {
lineBuffer = lines
flushBuffer()
return
}
for (const line of lines) {
lineBuffer.push(line)
}
if (!batchTimer) {
batchTimer = setTimeout(flushBuffer, BATCH_TIMEOUT_MS)
}
}
function addLog4jEvent(event: Log4jEvent) {
addLines(formatLog4jLines(event))
}
// 历史日志/缓冲大文本一次进入数据(保持行序),渲染由高亮管线分帧消化,
// 避免分块写入把实时事件插到历史行之间造成顺序错乱
function addLegacyLog(message: string): Promise<void> {
const lines = message
.split(/[\r\n]+/)
.filter(Boolean)
.map(textToLogLine)
let parentLevel: LogLevel | null = null
for (const line of lines) {
if (ENTRY_START_RE.test(line.text)) {
parentLevel = line.level
} else if (line.level === null && parentLevel !== null) {
line.level = parentLevel
}
}
appendLines(lines)
triggerRef(output)
return Promise.resolve()
}
function clear() {
output.value = []
outputTextLength = 0
lineBuffer = []
if (batchTimer) {
clearTimeout(batchTimer)
batchTimer = null
}
}
return {
output,
addLog4jEvent,
addLegacyLog,
clear,
}
}

View File

@ -0,0 +1,28 @@
function getCallerLocation(): string {
try {
const stack = new Error().stack
if (!stack) return ''
const lines = stack.split('\n')
const callerLine = lines[3]
if (!callerLine) return ''
const match = callerLine.match(/(https?:\/\/.+?|file:\/\/.+?|\/.*?):(\d+):\d+/)
if (!match) return ''
const [, fullPath, line] = match
const fileName = fullPath.split('/').pop()?.split('?')[0] || fullPath
return `${fileName}:${line}`
} catch {
return ''
}
}
export function useDebugLogger(namespace: string) {
// eslint-disable-next-line
return (...args: any[]) => {
const location = getCallerLocation()
const prefix = location ? `[${namespace}] ${location}` : `[${namespace}]`
console.debug(prefix, ...args)
}
}

View File

@ -0,0 +1,117 @@
import { useElementSize } from '@vueuse/core'
import { computed, onMounted, onUnmounted, type Ref } from 'vue'
export interface DynamicFontSizeOptions {
containerElement: Ref<HTMLElement | null>
text: Ref<string | undefined>
baseFontSize?: number
minFontSize?: number
maxFontSize?: number
availableWidthRatio?: number
maxContainerWidth?: number
padding?: number
fontFamily?: string
fontWeight?: string | number
}
export function useDynamicFontSize(options: DynamicFontSizeOptions) {
const {
containerElement,
text,
baseFontSize = 1.25,
minFontSize = 0.75,
maxFontSize = 2,
availableWidthRatio = 0.9,
maxContainerWidth = 400,
padding = 24,
fontFamily = 'inherit',
fontWeight = 'inherit',
} = options
const { width: containerWidth } = useElementSize(containerElement)
let measurementElement: HTMLElement | null = null
const createMeasurementElement = () => {
if (measurementElement) return measurementElement
measurementElement = document.createElement('div')
measurementElement.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
opacity: 0;
pointer-events: none;
white-space: nowrap;
font-family: ${fontFamily};
font-weight: ${fontWeight};
`
measurementElement.setAttribute('aria-hidden', 'true')
document.body.appendChild(measurementElement)
return measurementElement
}
const cleanupMeasurementElement = () => {
if (measurementElement?.parentNode) {
measurementElement.parentNode.removeChild(measurementElement)
measurementElement = null
}
}
const measureTextWidth = (textContent: string, fontSize: number): number => {
if (!textContent) return 0
const element = createMeasurementElement()
element.style.fontSize = `${fontSize}rem`
element.textContent = textContent
return element.getBoundingClientRect().width
}
const findOptimalFontSize = (textContent: string, availableWidth: number): number => {
let low = minFontSize
let high = maxFontSize
let bestSize = minFontSize
const maxWidth = measureTextWidth(textContent, maxFontSize)
if (maxWidth <= availableWidth) return maxFontSize
for (let i = 0; i < 8; i++) {
const mid = (low + high) / 2
const width = measureTextWidth(textContent, mid)
if (width <= availableWidth) {
bestSize = mid
low = mid
} else {
high = mid
}
if (high - low < 0.01) break
}
return Math.max(bestSize, minFontSize)
}
const fontSize = computed(() => {
if (!text.value || !containerWidth.value) return `${baseFontSize}rem`
const availableWidth =
Math.min(containerWidth.value * availableWidthRatio, maxContainerWidth) - padding
const baseWidth = measureTextWidth(text.value, baseFontSize)
if (baseWidth <= availableWidth) return `${baseFontSize}rem`
const optimalSize = findOptimalFontSize(text.value, availableWidth)
return `${optimalSize}rem`
})
onMounted(createMeasurementElement)
onUnmounted(cleanupMeasurementElement)
return {
fontSize,
containerWidth,
cleanup: cleanupMeasurementElement,
}
}

View File

@ -0,0 +1,39 @@
import { defineMessage, useVIntl } from '#ui/composables/i18n.ts'
const messages = [
defineMessage({
id: 'format.bytes.0',
defaultMessage: '{count, plural, one {# byte} other {# bytes}}',
}),
defineMessage({
id: 'format.bytes.1',
defaultMessage: '{count, number} KiB',
}),
defineMessage({
id: 'format.bytes.2',
defaultMessage: '{count, number} MiB',
}),
defineMessage({
id: 'format.bytes.3',
defaultMessage: '{count, number} GiB',
}),
defineMessage({
id: 'format.bytes.4',
defaultMessage: '{count, number} TiB',
}),
]
export function useFormatBytes() {
const { formatMessage } = useVIntl()
function format(bytes: number, decimals = 2): string {
if (bytes === 0) return formatMessage(messages[0], { count: 0 })
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), messages.length - 1)
return formatMessage(messages[exponent], {
count: (bytes / Math.pow(1024, exponent)).toFixed(decimals),
})
}
return format
}

View File

@ -0,0 +1,38 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
const formatterCache = new LRUCache<string, Intl.DateTimeFormat>({ max: 40 })
export function useFormatDateTime(options?: Intl.DateTimeFormatOptions) {
const { locale } = injectI18n()
function format(date?: Date | number | string): string {
if (typeof date === 'number' || typeof date === 'string') {
date = new Date(date)
}
const formatter = getFormatter(locale.value, options)
return formatter!.format(date)
}
return format
}
function getFormatter(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
let cacheKey = locale
if (options) {
const entries = Object.entries(options)
.filter(([, value]) => value !== undefined)
.sort()
.map(([key, value]) => `${key}=${value}`)
cacheKey = [locale, ...entries].join(':')
}
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale, options)
formatterCache.set(cacheKey, formatter)
}
return formatter
}

View File

@ -0,0 +1,78 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 10 })
const maxDigitsCache = new LRUCache<string, number>({ max: 10 })
// `formatMoney(1234.56, 'USD')` → `$1,234.56`
export function useFormatMoney() {
const { locale } = injectI18n()
function format(number: number, currency = 'USD'): string {
try {
const formatter = getFormatter(locale.value, currency)
return formatter!.format(number)
} catch {
return `${currency} ${number.toFixed(2)}`
}
}
return format
}
// `formatPrice(123456, 'USD')` → `$1,234.56`
export function useFormatPrice() {
const { locale } = injectI18n()
function format(price: number, currency: string, trimZeros = false): string {
const maxDigits = getMaxDigits(currency)
const convertedPrice = price / Math.pow(10, maxDigits)
const minimumFractionDigits = trimZeros && Number.isInteger(convertedPrice) ? 0 : undefined
try {
const formatter = getFormatter(locale.value, currency, minimumFractionDigits)
return formatter.format(convertedPrice)
} catch {
return `${currency} ${convertedPrice}`
}
}
return format
}
function getFormatter(
locale: string,
currency: string,
minimumFractionDigits?: number,
): Intl.NumberFormat {
const cacheKey = `${locale}:${currency}:${minimumFractionDigits}`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits,
})
formatterCache.set(cacheKey, formatter)
}
return formatter
}
function getMaxDigits(currency: string): number {
let maxDigits = maxDigitsCache.get(currency)
if (!maxDigits) {
try {
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
})
maxDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2
} catch {
maxDigits = 2
}
maxDigitsCache.set(currency, maxDigits)
}
return maxDigits
}

View File

@ -0,0 +1,72 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 15 })
// `formatNumber(1234567)` → `1,234,567`
export function useFormatNumber() {
const { locale } = injectI18n()
function format(value: number | bigint): string {
const formatter = getStandardFormatter(locale.value)
return formatter!.format(value)
}
return format
}
// `formatCompactNumber(1234567)` → `1.23M`
//
// Use `formatCompactNumberPlural` over `{(here!), plural, one {...} other {...}}`
export function useCompactNumber() {
const { locale } = injectI18n()
function formatCompactNumber(value: number | bigint): string {
if (value < 10_000) {
const standardFormatter = getStandardFormatter(locale.value)
return standardFormatter.format(value)
}
if (value < 1_000_000) {
const oneDigitCompactFormatter = getCompactFormatter('en', 1)
return oneDigitCompactFormatter.format(value)
}
const twoDigitsCompactFormatter = getCompactFormatter('en', 2)
return twoDigitsCompactFormatter.format(value)
}
function formatCompactNumberPlural(value: number | bigint): number | bigint {
if (value < 10_000) {
return value
}
const currentLocale = locale.value
const localeDefinition = LOCALES.find((l) => l.code === currentLocale)
return localeDefinition?.compactNumberPlural ?? NaN
}
return { formatCompactNumber, formatCompactNumberPlural }
}
function getStandardFormatter(locale: string): Intl.NumberFormat {
const cacheKey = `${locale}:standard`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.NumberFormat(locale)
formatterCache.set(cacheKey, formatter)
}
return formatter
}
function getCompactFormatter(locale: string, maximumFractionDigits: number): Intl.NumberFormat {
const cacheKey = `${locale}:compact:${maximumFractionDigits}`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits,
})
formatterCache.set(cacheKey, formatter)
}
return formatter
}

View File

@ -0,0 +1,70 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ max: 15 })
export function useRelativeTime(options?: Intl.RelativeTimeFormatOptions) {
const { locale } = injectI18n()
return (value: Date | number | string | null | undefined) => {
if (value == null) {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const now = Date.now()
const diff = date.getTime() - now
const seconds = Math.round(diff / 1000)
const minutes = Math.round(diff / 60000)
const hours = Math.round(diff / 3600000)
const days = Math.round(diff / 86400000)
const weeks = Math.round(diff / 604800000)
const months = Math.round(diff / 2629746000)
const years = Math.round(diff / 31556952000)
const rtf = getFormatter(locale.value, options)
if (Math.abs(seconds) < 60) {
return rtf.format(seconds, 'second')
} else if (Math.abs(minutes) < 60) {
return rtf.format(minutes, 'minute')
} else if (Math.abs(hours) < 24) {
return rtf.format(hours, 'hour')
} else if (Math.abs(days) < 7) {
return rtf.format(days, 'day')
} else if (Math.abs(weeks) < 4) {
return rtf.format(weeks, 'week')
} else if (Math.abs(months) < 12) {
return rtf.format(months, 'month')
} else {
return rtf.format(years, 'year')
}
}
}
function getFormatter(
locale: string,
options?: Intl.RelativeTimeFormatOptions,
): Intl.RelativeTimeFormat {
const localeDefinition = LOCALES.find((loc) => loc.code === locale)
const numeric = options?.numeric ?? localeDefinition?.numeric ?? 'auto'
const style = options?.style ?? 'long'
const cacheKey = `${locale}:${numeric}:${style}`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.RelativeTimeFormat(locale, {
...options,
numeric,
style,
})
formatterCache.set(cacheKey, formatter)
}
return formatter
}

View File

@ -0,0 +1,223 @@
import type { InjectionKey, Ref } from 'vue'
import { inject, provide, watch } from 'vue'
export interface RegistryEntry {
key: string
value: string
defaultMessage?: string
timestamp: number
}
export interface I18nDebugContext {
enabled: Ref<boolean>
keyReveal: Ref<boolean>
registry: Map<string, RegistryEntry>
panelOpen: Ref<boolean>
}
export const I18N_DEBUG_KEY: InjectionKey<I18nDebugContext> = Symbol('i18n-debug')
export function provideI18nDebug(context: I18nDebugContext): void {
provide(I18N_DEBUG_KEY, context)
}
export function injectI18nDebug(): I18nDebugContext | null {
return inject(I18N_DEBUG_KEY, null)
}
export function buildCrowdinUrl(key: string, locale: string): string {
return `https://crowdin.com/translate/modrinth-platform/all/en-${locale}?filter=basic&value=0&search_type=identifier&search=${encodeURIComponent(key)}`
}
export function initI18nDebugRuntime(context: I18nDebugContext): void {
import('@modrinth/assets/styles/i18n-debug.css')
document.body.classList.add('i18n-debug')
startMutationObserver(context.registry, context.keyReveal)
setupKeyTooltip()
registerKeyboardShortcuts(context.panelOpen, context.keyReveal)
}
function startMutationObserver(registry: Map<string, RegistryEntry>, keyReveal: Ref<boolean>) {
let pending = false
const observer = new MutationObserver((mutations) => {
if (pending || keyReveal.value) return
pending = true
requestAnimationFrame(() => {
pending = false
if (!keyReveal.value) {
processMutations(mutations, registry)
}
})
})
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
})
// Re-annotate whenever the registry grows (keys register after render)
let annotateTimer: ReturnType<typeof setTimeout> | undefined
let lastSize = 0
watch(
() => registry.size,
(size) => {
if (size <= lastSize || keyReveal.value) return
lastSize = size
clearTimeout(annotateTimer)
annotateTimer = setTimeout(() => annotateFullDocument(registry), 200)
},
{ immediate: true },
)
}
function processMutations(mutations: MutationRecord[], registry: Map<string, RegistryEntry>) {
const reverseLookup = new Map<string, string>()
for (const [, entry] of registry) {
if (entry.value) {
reverseLookup.set(entry.value, entry.key)
}
}
if (reverseLookup.size === 0) return
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
annotateTextNodes(node as Element, reverseLookup)
} else if (node.nodeType === Node.TEXT_NODE) {
annotateTextNode(node as Text, reverseLookup)
}
}
for (const node of mutation.removedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
clearStaleAttributes(node as Element)
}
}
} else if (mutation.type === 'characterData') {
if (mutation.target.nodeType === Node.TEXT_NODE) {
annotateTextNode(mutation.target as Text, reverseLookup)
}
}
}
}
function annotateTextNodes(element: Element, reverseLookup: Map<string, string>) {
if (element.closest('.i18n-debug-panel')) return
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
let node: Text | null
while ((node = walker.nextNode() as Text | null)) {
annotateTextNode(node, reverseLookup)
}
}
function annotateTextNode(node: Text, reverseLookup: Map<string, string>) {
const parent = node.parentElement
if (!parent || parent.closest('.i18n-debug-panel')) return
const text = node.textContent?.trim()
if (!text) return
const key = reverseLookup.get(text)
if (key) {
parent.setAttribute('data-i18n-key', key)
}
}
export function clearAllAnnotations() {
document.querySelectorAll('[data-i18n-key]').forEach((el) => {
el.removeAttribute('data-i18n-key')
})
}
export function hideKeyTooltip() {
const tooltip = document.querySelector('.i18n-key-tooltip') as HTMLElement | null
if (tooltip) tooltip.style.display = 'none'
}
function clearStaleAttributes(element: Element) {
if (element.hasAttribute?.('data-i18n-key')) {
element.removeAttribute('data-i18n-key')
}
const children = element.querySelectorAll?.('[data-i18n-key]')
if (children) {
for (const child of children) {
child.removeAttribute('data-i18n-key')
}
}
}
export function annotateFullDocument(registry: Map<string, RegistryEntry>) {
const reverseLookup = new Map<string, string>()
for (const [, entry] of registry) {
if (entry.value) {
reverseLookup.set(entry.value, entry.key)
}
}
if (reverseLookup.size === 0) return
annotateTextNodes(document.body, reverseLookup)
}
function setupKeyTooltip() {
const tooltip = document.createElement('div')
tooltip.className = 'i18n-key-tooltip'
tooltip.style.display = 'none'
document.body.appendChild(tooltip)
let activeTarget: Element | null = null
function positionTooltip() {
if (!activeTarget) return
const rect = activeTarget.getBoundingClientRect()
const tooltipRect = tooltip.getBoundingClientRect()
let top = rect.top - tooltipRect.height - 6
if (top < 4) top = rect.bottom + 6
let left = rect.left
if (left + tooltipRect.width > window.innerWidth - 4) {
left = window.innerWidth - tooltipRect.width - 4
}
tooltip.style.top = `${top}px`
tooltip.style.left = `${left}px`
}
document.body.addEventListener('mouseover', (e) => {
const target = (e.target as Element).closest?.('[data-i18n-key]')
if (!target) return
const key = target.getAttribute('data-i18n-key')
if (!key) return
activeTarget = target
tooltip.textContent = key
tooltip.style.display = ''
positionTooltip()
})
document.body.addEventListener('mouseout', (e) => {
const target = (e.target as Element).closest?.('[data-i18n-key]')
if (!target) return
const related = (e as MouseEvent).relatedTarget as Element | null
if (related?.closest?.('[data-i18n-key]') === target) return
activeTarget = null
tooltip.style.display = 'none'
})
document.addEventListener('scroll', () => positionTooltip(), { capture: true, passive: true })
}
function registerKeyboardShortcuts(panelOpen: Ref<boolean>, keyReveal: Ref<boolean>) {
document.addEventListener('keydown', (e: KeyboardEvent) => {
// Use Cmd on macOS, Ctrl on other platforms
const mod = e.metaKey || e.ctrlKey
if (!mod || !e.shiftKey) return
if (e.code === 'Period') {
e.preventDefault()
panelOpen.value = !panelOpen.value
} else if (e.code === 'KeyK') {
e.preventDefault()
keyReveal.value = !keyReveal.value
}
})
}

View File

@ -0,0 +1,344 @@
import IntlMessageFormat from 'intl-messageformat'
import type { Ref } from 'vue'
import type { CompileError, MessageCompiler, MessageContext } from 'vue-i18n'
import { injectI18n } from '../providers/i18n'
import { injectI18nDebug } from './i18n-debug'
export interface MessageDescriptor {
id: string
defaultMessage?: string
description?: string
}
export type MessageDescriptorMap<K extends string> = Record<K, MessageDescriptor>
export type CrowdinMessages = Record<string, { message?: string; defaultMessage?: string } | string>
export function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor
}
export function defineMessages<K extends string, T extends MessageDescriptorMap<K>>(
descriptors: T,
): T {
return descriptors
}
export interface LocaleDefinition {
code: string
name: string
translatedName: MessageDescriptor
numeric?: Intl.RelativeTimeFormatNumeric
compactNumberPlural?: number
dir?: 'ltr' | 'rtl'
serverLanguageCode?: string
}
export const LOCALES: LocaleDefinition[] = [
// Commented out as it's RTL - will enable when we have better RTL support
// {
// code: 'ar-SA',
// name: 'العربية (السعودية)',
// translatedName: defineMessage({ id: 'locale.ar-SA', defaultMessage: 'Arabic' }),
// dir: 'rtl',
// },
{
code: 'cs-CZ',
name: 'Čeština',
translatedName: defineMessage({ id: 'locale.cs-CZ', defaultMessage: 'Czech' }),
},
{
code: 'da-DK',
name: 'Dansk',
translatedName: defineMessage({ id: 'locale.da-DK', defaultMessage: 'Danish' }),
},
{
code: 'de-CH',
name: 'Deutsch (Schweiz)',
translatedName: defineMessage({ id: 'locale.de-CH', defaultMessage: 'German (Switzerland)' }),
},
{
code: 'de-DE',
name: 'Deutsch (Deutschland)',
translatedName: defineMessage({ id: 'locale.de-DE', defaultMessage: 'German (Germany)' }),
},
{
code: 'en-US',
name: 'English (United States)',
translatedName: defineMessage({
id: 'locale.en-US',
defaultMessage: 'English (United States)',
}),
},
{
code: 'es-419',
name: 'Español (Latinoamérica)',
translatedName: defineMessage({
id: 'locale.es-419',
defaultMessage: 'Spanish (Latin America)',
}),
},
{
code: 'es-ES',
name: 'Español (España)',
translatedName: defineMessage({ id: 'locale.es-ES', defaultMessage: 'Spanish (Spain)' }),
},
{
code: 'fi-FI',
name: 'Suomi',
translatedName: defineMessage({ id: 'locale.fi-FI', defaultMessage: 'Finnish' }),
},
{
code: 'fil-PH',
name: 'Filipino',
translatedName: defineMessage({ id: 'locale.fil-PH', defaultMessage: 'Filipino' }),
compactNumberPlural: 1,
serverLanguageCode: 'tl',
},
{
code: 'fr-FR',
name: 'Français',
translatedName: defineMessage({ id: 'locale.fr-FR', defaultMessage: 'French' }),
},
{
code: 'he-IL',
name: 'עברית',
translatedName: defineMessage({ id: 'locale.he-IL', defaultMessage: 'Hebrew' }),
dir: 'rtl',
},
{
code: 'hu-HU',
name: 'Magyar',
translatedName: defineMessage({ id: 'locale.hu-HU', defaultMessage: 'Hungarian' }),
},
{
code: 'id-ID',
name: 'Bahasa Indonesia',
translatedName: defineMessage({ id: 'locale.id-ID', defaultMessage: 'Indonesian' }),
},
{
code: 'it-IT',
name: 'Italiano (Italia)',
translatedName: defineMessage({ id: 'locale.it-IT', defaultMessage: 'Italian (Italy)' }),
numeric: 'always',
},
{
code: 'ja-JP',
name: '日本語',
translatedName: defineMessage({ id: 'locale.ja-JP', defaultMessage: 'Japanese' }),
},
{
code: 'ko-KR',
name: '한국어',
translatedName: defineMessage({ id: 'locale.ko-KR', defaultMessage: 'Korean' }),
},
{
code: 'ms-MY',
name: 'Bahasa Melayu',
translatedName: defineMessage({ id: 'locale.ms-MY', defaultMessage: 'Malay' }),
},
{
code: 'nl-NL',
name: 'Nederlands',
translatedName: defineMessage({ id: 'locale.nl-NL', defaultMessage: 'Dutch' }),
},
{
code: 'no-NO',
name: 'Norsk (Bokmål)',
translatedName: defineMessage({ id: 'locale.no-NO', defaultMessage: 'Norwegian Bokmål' }),
},
{
code: 'pl-PL',
name: 'Polski',
translatedName: defineMessage({ id: 'locale.pl-PL', defaultMessage: 'Polish' }),
},
{
code: 'pt-BR',
name: 'Português (Brasil)',
translatedName: defineMessage({ id: 'locale.pt-BR', defaultMessage: 'Portuguese (Brazil)' }),
},
{
code: 'pt-PT',
name: 'Português (Portugal)',
translatedName: defineMessage({ id: 'locale.pt-PT', defaultMessage: 'Portuguese (Portugal)' }),
},
{
code: 'ro-RO',
name: 'Română',
translatedName: defineMessage({ id: 'locale.ro-RO', defaultMessage: 'Romanian' }),
},
{
code: 'ru-RU',
name: 'Русский',
translatedName: defineMessage({ id: 'locale.ru-RU', defaultMessage: 'Russian' }),
numeric: 'always',
},
{
code: 'sr-CS',
name: 'Srpski (latinica)',
translatedName: defineMessage({ id: 'locale.sr-CS', defaultMessage: 'Serbian (Latin)' }),
},
{
code: 'sv-SE',
name: 'Svenska',
translatedName: defineMessage({ id: 'locale.sv-SE', defaultMessage: 'Swedish' }),
},
{
code: 'th-TH',
name: 'ไทย',
translatedName: defineMessage({ id: 'locale.th-TH', defaultMessage: 'Thai' }),
},
{
code: 'tr-TR',
name: 'Türkçe',
translatedName: defineMessage({ id: 'locale.tr-TR', defaultMessage: 'Turkish' }),
},
{
code: 'uk-UA',
name: 'Українська',
translatedName: defineMessage({ id: 'locale.uk-UA', defaultMessage: 'Ukrainian' }),
},
{
code: 'vi-VN',
name: 'Tiếng Việt',
translatedName: defineMessage({ id: 'locale.vi-VN', defaultMessage: 'Vietnamese' }),
},
{
code: 'zh-CN',
name: '简体中文',
translatedName: defineMessage({ id: 'locale.zh-CN', defaultMessage: 'Chinese (Simplified)' }),
},
{
code: 'zh-TW',
name: '繁體中文',
translatedName: defineMessage({ id: 'locale.zh-TW', defaultMessage: 'Chinese (Traditional)' }),
},
]
export function transformCrowdinMessages(messages: CrowdinMessages): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(messages)) {
if (typeof value === 'string') {
result[key] = value
} else if (typeof value === 'object' && value !== null) {
const msg = value.message ?? value.defaultMessage
if (msg) {
result[key] = msg
}
}
}
return result
}
const LOCALE_CODES = new Set(LOCALES.map((l) => l.code))
/**
* Builds locale messages from glob-imported modules.
* Only includes locales that are defined in the LOCALES array.
* Usage: buildLocaleMessages(import.meta.glob('./locales/* /index.json', { eager: true }))
*/
export function buildLocaleMessages(
...allModules: Record<string, { default: CrowdinMessages }>[]
): Record<string, Record<string, string>> {
const messages: Record<string, Record<string, string>> = {}
for (const modules of allModules) {
for (const [path, module] of Object.entries(modules)) {
// Extract locale code from path like './locales/en-US/index.json', './src/locales/en-US/index.json' or './locales/en-US/meta.json'
const match = path.match(/\/([^/]+)\/(index|meta)\.json$/)
if (match) {
const locale = match[1]
// Only include locales that are in our LOCALES list
if (LOCALE_CODES.has(locale)) {
const mergedMessages = messages[locale] ?? {}
messages[locale] = Object.assign(mergedMessages, transformCrowdinMessages(module.default))
}
}
}
}
return messages
}
/**
* Creates a vue-i18n message compiler that uses IntlMessageFormat for ICU syntax support.
* This enables pluralization, select, and other ICU message features.
*/
export function createMessageCompiler(): MessageCompiler {
return (msg, { locale, key, onError }) => {
let messageString: string
if (typeof msg === 'string') {
messageString = msg
} else if (typeof msg === 'object' && msg !== null && 'message' in msg) {
messageString = (msg as { message: string }).message
} else {
onError?.(new Error('Invalid message format') as CompileError)
return () => key
}
try {
const formatter = new IntlMessageFormat(messageString, locale)
return (ctx: MessageContext) => {
try {
return formatter.format(ctx.values as Record<string, unknown>) as string
} catch {
return messageString
}
}
} catch (e) {
onError?.(e as CompileError)
return () => key
}
}
}
export interface VIntlFormatters {
formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string
}
/**
* Composable that provides formatMessage() with the same API as @vintl/vintl.
* Uses the injected I18nContext from the provider.
*/
export function useVIntl(): VIntlFormatters & { locale: Ref<string> } {
const { t, locale } = injectI18n()
const debugContext = injectI18nDebug()
function formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string {
// Read locale.value to ensure Vue tracks this as a reactive dependency
// when formatMessage is called during component render
void locale.value
const key = descriptor.id
const translation = t(key, values ?? {})
let result: string
if (translation && translation !== key) {
result = translation as string
} else {
// Fallback to defaultMessage if key not found
const defaultMsg = descriptor.defaultMessage ?? key
try {
const formatter = new IntlMessageFormat(defaultMsg, locale.value)
result = formatter.format(values ?? {}) as string
} catch {
result = defaultMsg
}
}
if (debugContext?.enabled.value) {
debugContext.registry.set(key, {
key,
value: result,
defaultMessage: descriptor.defaultMessage,
timestamp: Date.now(),
})
if (debugContext.keyReveal.value) {
return `\u300C${key}\u300D`
}
}
return result
}
return { formatMessage, locale }
}

View File

@ -0,0 +1,22 @@
export * from './console-state'
export * from './debug-logger'
export * from './dynamic-font-size'
export * from './format-bytes'
export * from './format-date-time'
export * from './format-money'
export * from './format-number'
export * from './how-ago'
export * from './i18n'
export * from './i18n-debug'
export * from './page-leave-safety'
export * from './scroll-indicator'
export * from './sticky-observer'
export * from './terminal'
export * from './use-batch-drop'
export * from './use-global-drop'
export * from './use-instance-context'
export * from './use-loading-bar-token'
export * from './use-loading-state-core'
export * from './use-ready-state'
export * from './use-server-image'
export * from './virtual-scroll'

View File

@ -0,0 +1,39 @@
import { computed, type Ref, ref } from 'vue'
const isClient = typeof window !== 'undefined'
const stack: symbol[] = []
const stackSizeRef = ref(0)
export function useModalStack() {
const id = Symbol()
function push() {
if (isClient && !stack.includes(id)) {
stack.push(id)
stackSizeRef.value = stack.length
}
}
function pop() {
if (!isClient) return
const idx = stack.indexOf(id)
if (idx !== -1) {
stack.splice(idx, 1)
stackSizeRef.value = stack.length
}
}
function isTopmost() {
if (!isClient) return true
return stack.length === 0 || stack[stack.length - 1] === id
}
function stackSize() {
return isClient ? stack.length : 0
}
const hasModal = computed(() => stackSizeRef.value > 0)
const stackCount: Readonly<Ref<number>> = stackSizeRef
return { push, pop, isTopmost, stackSize, hasModal, stackCount }
}

View File

@ -0,0 +1,38 @@
import type { ComputedRef, Ref } from 'vue'
import { onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import type ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
export function usePageLeaveSafety(dirty: Ref<boolean> | ComputedRef<boolean>) {
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) {
e.preventDefault()
}
}
if (typeof window !== 'undefined') {
watch(dirty, (isDirty) => {
if (isDirty) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
if (dirty.value) {
return (await confirmLeaveModal.value?.prompt()) ?? false
}
return true
})
}
return { confirmLeaveModal }
}

View File

@ -0,0 +1,181 @@
import { nextTick, onUnmounted, type Ref, ref, watchEffect } from 'vue'
import { useDebugLogger } from './debug-logger'
export interface ScrollIndicatorOptions {
watchContent?: boolean
debounceMs?: number
tolerance?: number
debug?: boolean
}
export interface ScrollIndicator {
showTopFade: Ref<boolean>
showBottomFade: Ref<boolean>
checkScrollState: () => void
forceCheck: () => void
}
export function useScrollIndicator(
containerRef: Ref<HTMLElement | null>,
options: ScrollIndicatorOptions = {},
): ScrollIndicator {
const { watchContent = true, debounceMs = 0, tolerance = 1, debug = false } = options
const showTopFade = ref(false)
const showBottomFade = ref(false)
let resizeObserver: ResizeObserver | null = null
let mutationObserver: MutationObserver | null = null
let rafId: number | null = null
let debounceTimer: number | null = null
const log = useDebugLogger('ScrollIndicator')
const checkScrollStateInternal = () => {
const container = containerRef.value
if (!container) {
showTopFade.value = false
showBottomFade.value = false
if (debug) log('Container not found, hiding fades')
return
}
if (rafId) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
const { scrollTop, scrollHeight, clientHeight } = container
const isScrollable = scrollHeight > clientHeight + tolerance
if (debug) {
log('Checking scroll state', {
scrollTop,
scrollHeight,
clientHeight,
isScrollable,
})
}
if (!isScrollable) {
showTopFade.value = false
showBottomFade.value = false
if (debug) log('Content fits, no fades needed')
} else {
showTopFade.value = scrollTop > tolerance
showBottomFade.value = scrollTop < scrollHeight - clientHeight - tolerance
if (debug) {
log('Fades updated', {
showTop: showTopFade.value,
showBottom: showBottomFade.value,
})
}
}
})
}
const checkScrollState = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = window.setTimeout(() => {
checkScrollStateInternal()
}, debounceMs)
}
const forceCheck = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
checkScrollStateInternal()
}
watchEffect((onCleanup) => {
const container = containerRef.value
if (!container) {
if (debug) log('No container, skipping setup')
return
}
if (debug) log('Setting up observers for container', container)
nextTick(() => {
forceCheck()
})
resizeObserver = new ResizeObserver(() => {
if (debug) log('ResizeObserver triggered')
checkScrollState()
})
resizeObserver.observe(container)
if (watchContent) {
mutationObserver = new MutationObserver(() => {
if (debug) log('MutationObserver triggered')
checkScrollState()
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
attributes: false,
})
}
const handleScroll = () => {
if (debug) log('Scroll event triggered')
checkScrollState()
}
container.addEventListener('scroll', handleScroll, { passive: true })
const handleResize = () => {
if (debug) log('Window resize triggered')
checkScrollState()
}
window.addEventListener('resize', handleResize, { passive: true })
onCleanup(() => {
if (debug) log('Cleaning up observers and listeners')
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
resizeObserver?.disconnect()
resizeObserver = null
mutationObserver?.disconnect()
mutationObserver = null
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
})
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
if (rafId) {
cancelAnimationFrame(rafId)
}
})
return {
showTopFade,
showBottomFade,
checkScrollState,
forceCheck,
}
}

View File

@ -0,0 +1,6 @@
export * from './types'
export * from './use-skin-preview-animation'
export * from './use-skin-preview-controls'
export * from './use-skin-preview-fit'
export * from './use-skin-preview-loading'
export * from './use-skin-preview-scene'

View File

@ -0,0 +1,28 @@
export interface SkinPreviewAnimationConfig {
baseAnimation: string
randomAnimations: string[]
randomAnimationInterval?: number
transitionDuration?: number
}
export type SkinPreviewFraming = 'page' | 'modal'
export interface SkinPreviewFitPadding {
top: number
right: number
bottom: number
left: number
}
export interface SkinPreviewFitLock {
containerSize: {
width: number
height: number
}
modelCenter: SkinPreviewTuple
modelSize: SkinPreviewTuple
padding: SkinPreviewFitPadding
rotation: number
}
export type SkinPreviewTuple = [number, number, number]

View File

@ -0,0 +1,408 @@
import { useRenderLoop } from '@tresjs/core'
import * as THREE from 'three'
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
import type { SkinPreviewAnimationConfig } from './types'
type AnimationFinishedListener = (
event: THREE.AnimationMixerEventMap['finished'] & {
readonly type: 'finished'
readonly target: THREE.AnimationMixer
},
) => void
export const INTERACT_ANIMATION_NAME = 'interact'
const INTERACT_VISIBLE_DURATION_SECONDS = 0.5
const CLICK_IMPULSE_MAX_ENERGY = 5
const CLICK_IMPULSE_ENERGY_PER_CLICK = 1
const DAMAGE_FLASH_MIN_CLICKS_PER_SECOND = 2
const CLICK_IMPULSE_DECAY_PER_SECOND =
DAMAGE_FLASH_MIN_CLICKS_PER_SECOND * CLICK_IMPULSE_ENERGY_PER_CLICK
const CLICK_IMPULSE_BASE_SPEED = 18
const CLICK_IMPULSE_SPEED_BOOST = 7
const CLICK_IMPULSE_OFFSET_X = 0.035
const CLICK_IMPULSE_ROTATION_Z = 0.055
const CLICK_IMPULSE_SCALE_X = 0.018
const CLICK_IMPULSE_SCALE_Y = 0.025
const DAMAGE_FLASH_DURATION_SECONDS = 0.2
const DAMAGE_FLASH_REPEAT_DELAY_SECONDS = 0.5
const DAMAGE_FLASH_MAX_INTENSITY = 0.7
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewAnimation(
animationConfig: MaybeReadonlyRef<SkinPreviewAnimationConfig | undefined>,
) {
const mixer = ref<THREE.AnimationMixer | null>(null)
const actions = ref<Record<string, THREE.AnimationAction>>({})
const clock = new THREE.Clock()
const currentAnimation = ref<string>('')
const randomAnimationTimer = ref<number | null>(null)
const lastRandomAnimation = ref<string>('')
const animationFinishedListeners: AnimationFinishedListener[] = []
const clickImpulseEnergy = ref(0)
const clickImpulsePhase = ref(0)
const clickImpulseOffsetX = ref(0)
const clickImpulseRotationZ = ref(0)
const clickImpulseScaleX = ref(1)
const clickImpulseScaleY = ref(1)
const damageFlashIntensity = ref(0)
let damageFlashRemainingSeconds = 0
let damageFlashCooldownSeconds = 0
const baseAnimation = computed(() => animationConfig.value?.baseAnimation ?? '')
const randomAnimations = computed(() => animationConfig.value?.randomAnimations ?? [])
const transitionDuration = computed(() => animationConfig.value?.transitionDuration || 0.3)
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
if (!clips || clips.length === 0) {
console.warn('No animation clips found in the model')
return
}
mixer.value = new THREE.AnimationMixer(loadedScene)
clock.start()
actions.value = {}
clips.forEach((clip) => {
if (clip.name === INTERACT_ANIMATION_NAME) {
clip.duration = INTERACT_VISIBLE_DURATION_SECONDS
}
const action = mixer.value!.clipAction(clip)
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
actions.value[clip.name] = action
})
if (baseAnimation.value && actions.value[baseAnimation.value]) {
actions.value[baseAnimation.value].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(baseAnimation.value, true)
setupRandomAnimationLoop()
} else {
console.warn(`Base animation "${baseAnimation.value}" not found`)
const firstAnimationName = Object.keys(actions.value)[0]
if (firstAnimationName) {
actions.value[firstAnimationName].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(firstAnimationName, true)
}
}
}
function playAnimation(name: string, immediate = false) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return false
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning() && name !== baseAnimation.value) {
console.log(`Animation "${name}" is already running, ignoring request`)
return false
}
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
if (actionName !== name && actionInstance.isRunning()) {
actionInstance.fadeOut(transitionDuration.value)
}
})
action.reset()
if (name === baseAnimation.value) {
action.setLoop(THREE.LoopRepeat, Infinity)
} else {
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
const onFinished: AnimationFinishedListener = (event) => {
if (event.action === action) {
removeAnimationFinishedListener(onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration.value)
const baseAction = actions.value[baseAnimation.value]
if (baseAction) {
baseAction.reset()
baseAction.fadeIn(transitionDuration.value)
baseAction.play()
currentAnimation.value = baseAnimation.value
}
}
}
}
addAnimationFinishedListener(onFinished)
}
if (immediate) {
action.setEffectiveWeight(1)
} else {
action.fadeIn(transitionDuration.value)
}
action.play()
if (immediate) {
mixer.value.update(0)
}
currentAnimation.value = name
return true
}
function setupRandomAnimationLoop() {
const interval = animationConfig.value?.randomAnimationInterval || 10000
function scheduleNextAnimation() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
randomAnimationTimer.value = window.setTimeout(() => {
if (randomAnimations.value.length > 0 && currentAnimation.value === baseAnimation.value) {
const availableAnimations = randomAnimations.value.filter(
(anim) => anim !== lastRandomAnimation.value,
)
const animationsToChooseFrom =
availableAnimations.length > 0 ? availableAnimations : randomAnimations.value
const randomIndex = Math.floor(Math.random() * animationsToChooseFrom.length)
const randomAnimationName = animationsToChooseFrom[randomIndex]
if (actions.value[randomAnimationName]) {
lastRandomAnimation.value = randomAnimationName
playRandomAnimation(randomAnimationName)
}
} else {
scheduleNextAnimation()
}
}, interval)
}
scheduleNextAnimation()
}
function playRandomAnimation(name: string) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning()) {
console.log(`Animation "${name}" is already running, ignoring request`)
return
}
const baseAction = baseAnimation.value ? actions.value[baseAnimation.value] : undefined
if (baseAction?.isRunning()) {
baseAction.fadeOut(transitionDuration.value)
}
action.reset()
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
action.setEffectiveTimeScale(1)
action.fadeIn(transitionDuration.value)
action.play()
currentAnimation.value = name
const onFinished: AnimationFinishedListener = (event) => {
if (event.action === action) {
removeAnimationFinishedListener(onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration.value)
const nextBaseAction = actions.value[baseAnimation.value]
if (nextBaseAction) {
nextBaseAction.reset()
nextBaseAction.setEffectiveTimeScale(1)
nextBaseAction.fadeIn(transitionDuration.value)
nextBaseAction.play()
currentAnimation.value = baseAnimation.value
setupRandomAnimationLoop()
}
}
}
}
addAnimationFinishedListener(onFinished)
}
function playInteractAnimation() {
if (actions.value[INTERACT_ANIMATION_NAME]) {
playRandomAnimation(INTERACT_ANIMATION_NAME)
}
}
function playClickInteraction() {
addClickImpulse()
playInteractAnimation()
}
function addClickImpulse() {
clickImpulseEnergy.value = Math.min(
CLICK_IMPULSE_MAX_ENERGY,
clickImpulseEnergy.value + CLICK_IMPULSE_ENERGY_PER_CLICK,
)
if (clickImpulseEnergy.value >= CLICK_IMPULSE_MAX_ENERGY && damageFlashCooldownSeconds <= 0) {
triggerDamageFlash()
}
}
function updateClickImpulse(delta: number) {
const energy = Math.max(0, clickImpulseEnergy.value - CLICK_IMPULSE_DECAY_PER_SECOND * delta)
clickImpulseEnergy.value = energy
if (energy <= 0) {
clickImpulseOffsetX.value = 0
clickImpulseRotationZ.value = 0
clickImpulseScaleX.value = 1
clickImpulseScaleY.value = 1
return
}
const intensity = energy / CLICK_IMPULSE_MAX_ENERGY
clickImpulsePhase.value +=
delta * (CLICK_IMPULSE_BASE_SPEED + energy * CLICK_IMPULSE_SPEED_BOOST)
const shake = Math.sin(clickImpulsePhase.value) * intensity
const squash = Math.abs(Math.sin(clickImpulsePhase.value * 1.7)) * intensity
clickImpulseOffsetX.value = shake * CLICK_IMPULSE_OFFSET_X
clickImpulseRotationZ.value = shake * CLICK_IMPULSE_ROTATION_Z
clickImpulseScaleX.value = 1 + squash * CLICK_IMPULSE_SCALE_X
clickImpulseScaleY.value = 1 - squash * CLICK_IMPULSE_SCALE_Y
}
function triggerDamageFlash() {
damageFlashRemainingSeconds = DAMAGE_FLASH_DURATION_SECONDS
damageFlashCooldownSeconds = DAMAGE_FLASH_DURATION_SECONDS + DAMAGE_FLASH_REPEAT_DELAY_SECONDS
damageFlashIntensity.value = DAMAGE_FLASH_MAX_INTENSITY
}
function updateDamageFlash(delta: number) {
damageFlashCooldownSeconds = Math.max(0, damageFlashCooldownSeconds - delta)
if (damageFlashRemainingSeconds <= 0) {
damageFlashIntensity.value = 0
return
}
damageFlashRemainingSeconds = Math.max(0, damageFlashRemainingSeconds - delta)
damageFlashIntensity.value =
DAMAGE_FLASH_MAX_INTENSITY * (damageFlashRemainingSeconds / DAMAGE_FLASH_DURATION_SECONDS)
}
function stopAnimations() {
if (mixer.value) {
mixer.value.stopAllAction()
}
currentAnimation.value = ''
}
function getAvailableAnimations(): string[] {
return Object.keys(actions.value)
}
function clearRandomAnimationTimer() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
randomAnimationTimer.value = null
}
}
function addAnimationFinishedListener(listener: AnimationFinishedListener) {
mixer.value?.addEventListener('finished', listener)
animationFinishedListeners.push(listener)
}
function removeAnimationFinishedListener(
listener: AnimationFinishedListener,
targetMixer = mixer.value,
) {
targetMixer?.removeEventListener('finished', listener)
const index = animationFinishedListeners.indexOf(listener)
if (index !== -1) {
animationFinishedListeners.splice(index, 1)
}
}
function clearAnimationFinishedListeners(targetMixer = mixer.value) {
animationFinishedListeners.forEach((listener) => {
targetMixer?.removeEventListener('finished', listener)
})
animationFinishedListeners.length = 0
}
function cleanupAnimationState(root: THREE.Object3D | null) {
clearRandomAnimationTimer()
const currentMixer = mixer.value
if (currentMixer) {
clearAnimationFinishedListeners(currentMixer)
currentMixer.stopAllAction()
if (root) {
currentMixer.uncacheRoot(root)
}
}
mixer.value = null
actions.value = {}
currentAnimation.value = ''
lastRandomAnimation.value = ''
damageFlashRemainingSeconds = 0
damageFlashCooldownSeconds = 0
damageFlashIntensity.value = 0
}
watch(
() => animationConfig.value,
(newConfig) => {
clearRandomAnimationTimer()
if (mixer.value && newConfig?.baseAnimation && actions.value[newConfig.baseAnimation]) {
playAnimation(newConfig.baseAnimation)
setupRandomAnimationLoop()
}
},
{ deep: true },
)
const { onLoop } = useRenderLoop()
onLoop(() => {
const delta = clock.getDelta()
if (mixer.value) {
mixer.value.update(delta)
}
updateClickImpulse(delta)
updateDamageFlash(delta)
})
return {
clickImpulseOffsetX,
clickImpulseRotationZ,
clickImpulseScaleX,
clickImpulseScaleY,
cleanupAnimationState,
currentAnimation,
damageFlashIntensity,
getAvailableAnimations,
initializeAnimations,
playAnimation,
playClickInteraction,
stopAnimations,
}
}

View File

@ -0,0 +1,61 @@
import { type ComputedRef, type Ref, ref } from 'vue'
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewControls({
initialRotation,
onClickWithoutDrag,
}: {
initialRotation: MaybeReadonlyRef<number | undefined>
onClickWithoutDrag: () => void
}) {
const modelRotation = ref((initialRotation.value ?? 15.75) + Math.PI)
const isDragging = ref(false)
const previousX = ref(0)
const hasDragged = ref(false)
function onPointerDown(event: PointerEvent) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
isDragging.value = true
previousX.value = event.clientX
hasDragged.value = false
}
function onPointerMove(event: PointerEvent) {
if (!isDragging.value) return
const deltaX = event.clientX - previousX.value
modelRotation.value += deltaX * 0.01
previousX.value = event.clientX
hasDragged.value = true
}
function onPointerUp(event: PointerEvent) {
isDragging.value = false
const target = event.currentTarget as HTMLElement
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId)
}
}
function onCanvasClick() {
if (!hasDragged.value) {
onClickWithoutDrag()
}
hasDragged.value = false
}
function ignoreControlClick(event: MouseEvent) {
event.stopPropagation()
}
return {
ignoreControlClick,
modelRotation,
onCanvasClick,
onPointerDown,
onPointerMove,
onPointerUp,
}
}

View File

@ -0,0 +1,380 @@
import * as THREE from 'three'
import {
computed,
type ComputedRef,
type CSSProperties,
onMounted,
onUnmounted,
type Ref,
ref,
watch,
} from 'vue'
import type {
SkinPreviewFitLock,
SkinPreviewFitPadding,
SkinPreviewFraming,
SkinPreviewTuple,
} from './types'
const FRAMING_PRESETS = {
page: {
fov: 35,
zoom: 0.96,
padding: { top: 0.2, right: 0.14, bottom: 0.3, left: 0.14 },
},
modal: {
fov: 35,
zoom: 1,
padding: { top: 0.1, right: 0.1, bottom: 0.18, left: 0.1 },
},
} satisfies Record<
SkinPreviewFraming,
{ fov: number; zoom: number; padding: SkinPreviewFitPadding }
>
const PREVIEW_CONTROLS_FOOT_OFFSET = 64
const SUBTITLE_CONTROLS_OFFSET = 48
const NAMETAG_HEAD_OFFSET = 16
function cloneModelTuple(tuple: SkinPreviewTuple): SkinPreviewTuple {
return [tuple[0], tuple[1], tuple[2]]
}
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewFit({
containerElement,
fit,
lockFit,
framing,
fitZoom,
fitPadding,
scale,
fov,
modelRotation,
nametag,
hasSubtitle,
hasNametagBadge,
subtitleWrapped,
modelCenter,
modelSize,
isModelLoaded,
}: {
containerElement: MaybeReadonlyRef<HTMLElement | null>
fit: MaybeReadonlyRef<boolean | undefined>
lockFit: MaybeReadonlyRef<boolean | undefined>
framing: MaybeReadonlyRef<SkinPreviewFraming | undefined>
fitZoom: MaybeReadonlyRef<number | undefined>
fitPadding: MaybeReadonlyRef<Partial<SkinPreviewFitPadding> | undefined>
scale: MaybeReadonlyRef<number | undefined>
fov: MaybeReadonlyRef<number | undefined>
modelRotation: MaybeReadonlyRef<number>
nametag: MaybeReadonlyRef<string | undefined>
hasSubtitle: MaybeReadonlyRef<boolean>
hasNametagBadge: MaybeReadonlyRef<boolean>
subtitleWrapped: MaybeReadonlyRef<boolean>
modelCenter: MaybeReadonlyRef<SkinPreviewTuple>
modelSize: MaybeReadonlyRef<SkinPreviewTuple>
isModelLoaded: MaybeReadonlyRef<boolean>
}) {
const containerSize = ref({ width: 1, height: 1 })
const fitLock = ref<SkinPreviewFitLock | null>(null)
let resizeObserver: ResizeObserver | undefined
const fitEnabled = computed(() => {
if (fit.value !== undefined) return fit.value
return scale.value === undefined && fov.value === undefined
})
const currentFraming = computed<SkinPreviewFraming>(() => framing.value ?? 'page')
const lockFitEnabled = computed(() => currentFraming.value === 'page' || (lockFit.value ?? true))
const legacyScale = computed(() => scale.value ?? 1)
const legacyFov = computed(() => fov.value ?? 40)
const hasUsableFitSize = computed(
() => containerSize.value.width > 1 && containerSize.value.height > 1,
)
const hasResolvedFit = computed(
() =>
!fitEnabled.value || (lockFitEnabled.value ? fitLock.value !== null : hasUsableFitSize.value),
)
const fitContainerSize = computed(() =>
lockFitEnabled.value
? (fitLock.value?.containerSize ?? containerSize.value)
: containerSize.value,
)
const fitModelCenter = computed(() =>
lockFitEnabled.value ? (fitLock.value?.modelCenter ?? modelCenter.value) : modelCenter.value,
)
const fitModelSize = computed(() =>
lockFitEnabled.value ? (fitLock.value?.modelSize ?? modelSize.value) : modelSize.value,
)
const fitModelRotation = computed(() =>
lockFitEnabled.value ? (fitLock.value?.rotation ?? modelRotation.value) : modelRotation.value,
)
const resolvedFitPadding = computed<SkinPreviewFitPadding>(() => {
const preset = FRAMING_PRESETS[currentFraming.value].padding
return {
top: Math.max(preset.top, hasNametagBadge.value ? 0.28 : nametag.value ? 0.2 : 0),
right: preset.right,
bottom: Math.max(preset.bottom, hasSubtitle.value ? 0.28 : preset.bottom),
left: preset.left,
...(fitPadding.value ?? {}),
}
})
const fitResolvedPadding = computed(() =>
lockFitEnabled.value
? (fitLock.value?.padding ?? resolvedFitPadding.value)
: resolvedFitPadding.value,
)
const modelOffset = computed<SkinPreviewTuple>(() => {
if (!fitEnabled.value) return [0, 0, 0]
const [x, y, z] = fitModelCenter.value
return [-x, -y, -z]
})
const modelGroupPosition = computed<SkinPreviewTuple>(() => {
if (fitEnabled.value) return [0, 0, 0]
return [0, -0.05 * legacyScale.value, 1.95]
})
const modelGroupScale = computed<SkinPreviewTuple>(() => {
if (fitEnabled.value) return [1, 1, 1]
const resolvedScale = 0.8 * legacyScale.value
return [resolvedScale, resolvedScale, resolvedScale]
})
const fittedCamera = computed(() => {
const width = Math.max(fitContainerSize.value.width, 1)
const height = Math.max(fitContainerSize.value.height, 1)
const aspect = width / height
const preset = FRAMING_PRESETS[currentFraming.value]
const padding = fitResolvedPadding.value
const usableWidth = Math.max(width * (1 - padding.left - padding.right), 1)
const usableHeight = Math.max(height * (1 - padding.top - padding.bottom), 1)
const [sizeX, sizeY, sizeZ] = fitModelSize.value
const halfWidth = Math.sqrt((sizeX / 2) ** 2 + (sizeZ / 2) ** 2)
const halfHeight = sizeY / 2
const resolvedFov = fov.value ?? preset.fov
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect)
const paddedHalfWidth = halfWidth * (width / usableWidth)
const paddedHalfHeight = halfHeight * (height / usableHeight)
const zoom = Math.max((fitZoom.value ?? 1) * preset.zoom, 0.01)
const distance =
Math.max(
paddedHalfHeight / Math.tan(verticalFov / 2),
paddedHalfWidth / Math.tan(horizontalFov / 2),
) / zoom
const visibleHalfHeight = distance * Math.tan(verticalFov / 2)
const targetY = -(padding.bottom - padding.top) * visibleHalfHeight
return {
fov: resolvedFov,
position: [0, targetY, -distance] as SkinPreviewTuple,
target: [0, targetY, 0] as SkinPreviewTuple,
}
})
const cameraConfig = computed(() => {
if (fitEnabled.value) return fittedCamera.value
return {
fov: legacyFov.value,
position: [0, 1.5, -3.25] as SkinPreviewTuple,
target: modelCenter.value,
}
})
const modelFeetTop = computed(() => {
if (!fitEnabled.value) return null
const height = Math.max(containerSize.value.height, 1)
const [, sizeY] = fitModelSize.value
const { fov: resolvedFov, position, target } = cameraConfig.value
const distance = Math.max(Math.abs(position[2] - target[2]), 0.001)
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const modelFeetY = -sizeY / 2
const projectedY =
(modelFeetY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
const topPercent = THREE.MathUtils.clamp(((1 - projectedY) / 2) * 100, 0, 100)
return (topPercent / 100) * height
})
const previewControlsTop = computed(() =>
modelFeetTop.value === null ? null : modelFeetTop.value + PREVIEW_CONTROLS_FOOT_OFFSET,
)
const previewControlsPositionStyle = computed<CSSProperties>(() => {
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
return {
bottom: currentFraming.value === 'modal' ? '6%' : 'calc(15% + 64px)',
}
}
return {
top: `${previewControlsTop.value}px`,
}
})
const subtitlePositionStyle = computed<CSSProperties>(() => {
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
return {
bottom:
currentFraming.value === 'modal'
? '6%'
: subtitleWrapped.value
? 'calc(15% - 32px)'
: '15%',
}
}
return {
top: `${previewControlsTop.value + SUBTITLE_CONTROLS_OFFSET}px`,
}
})
const nametagTop = computed(() => {
if (!fitEnabled.value) return '18%'
const height = Math.max(containerSize.value.height, 1)
const [sizeX, sizeY, sizeZ] = fitModelSize.value
const { fov: resolvedFov, position, target } = cameraConfig.value
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const modelTopY = sizeY / 2
const halfX = sizeX / 2
const halfZ = sizeZ / 2
const sinRotation = Math.sin(fitModelRotation.value)
const cosRotation = Math.cos(fitModelRotation.value)
const modelTopZ = -Math.abs(halfX * sinRotation) - Math.abs(halfZ * cosRotation)
const distance = Math.max(Math.abs(position[2] - target[2]) + modelTopZ, 0.001)
const projectedY =
(modelTopY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
const topPercent = ((1 - projectedY) / 2) * 100
return `${(topPercent / 100) * height - NAMETAG_HEAD_OFFSET}px`
})
const spotlightY = computed(() => {
if (!fitEnabled.value) return -0.1 * legacyScale.value
const [, sizeY] = fitModelSize.value
return -sizeY / 2 - 0.02
})
const spotlightPosition = computed<SkinPreviewTuple>(() => [
0,
spotlightY.value,
fitEnabled.value ? 0 : 2,
])
const spotlightScale = computed<SkinPreviewTuple>(() => {
if (!fitEnabled.value) {
const resolvedScale = 0.75 * legacyScale.value
return [resolvedScale, resolvedScale, resolvedScale]
}
const [sizeX, , sizeZ] = fitModelSize.value
const radius = Math.max(sizeX, sizeZ, 1) * 0.8
return [radius, radius, radius]
})
function lockFitState() {
if (!fitEnabled.value || !lockFitEnabled.value || fitLock.value || !isModelLoaded.value) return
const { width, height } = containerSize.value
if (width <= 1 || height <= 1) return
fitLock.value = {
containerSize: { width, height },
modelCenter: cloneModelTuple(modelCenter.value),
modelSize: cloneModelTuple(modelSize.value),
padding: { ...resolvedFitPadding.value },
rotation: modelRotation.value,
}
}
function resetFitLockForLayoutChange() {
if (!fitEnabled.value || !lockFitEnabled.value) return
fitLock.value = null
lockFitState()
}
onMounted(() => {
const el = containerElement.value
if (!el) return
resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
const nextContainerSize = {
width: Math.max(width, 1),
height: Math.max(height, 1),
}
const didContainerSizeChange =
nextContainerSize.width !== containerSize.value.width ||
nextContainerSize.height !== containerSize.value.height
containerSize.value = nextContainerSize
if (didContainerSizeChange) {
resetFitLockForLayoutChange()
}
})
resizeObserver.observe(el)
})
watch(
() => isModelLoaded.value,
(loaded) => {
if (loaded) lockFitState()
},
)
watch(
() => lockFitEnabled.value,
() => {
fitLock.value = null
lockFitState()
},
)
watch(fitEnabled, () => {
fitLock.value = null
lockFitState()
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
return {
cameraConfig,
currentFraming,
fitEnabled,
hasResolvedFit,
legacyScale,
modelGroupPosition,
modelGroupScale,
modelOffset,
nametagTop,
previewControlsPositionStyle,
spotlightPosition,
spotlightScale,
subtitlePositionStyle,
}
}

View File

@ -0,0 +1,87 @@
import { computed, type ComputedRef, onUnmounted, type Ref, ref, watch } from 'vue'
const LOADING_INDICATOR_DELAY_MS = 200
const LOADING_INDICATOR_MIN_MS = 250
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewLoading(isReady: MaybeReadonlyRef<boolean>) {
const showLoading = ref(false)
const isPreviewVisible = computed(() => isReady.value && !showLoading.value)
let loadingIndicatorDelayTimer: number | null = null
let loadingIndicatorMinTimer: number | null = null
let loadingIndicatorShownAt = 0
function clearLoadingIndicatorDelayTimer() {
if (loadingIndicatorDelayTimer !== null) {
clearTimeout(loadingIndicatorDelayTimer)
loadingIndicatorDelayTimer = null
}
}
function clearLoadingIndicatorMinTimer() {
if (loadingIndicatorMinTimer !== null) {
clearTimeout(loadingIndicatorMinTimer)
loadingIndicatorMinTimer = null
}
}
function hideLoadingIndicatorAfterMinimum() {
const visibleFor = Date.now() - loadingIndicatorShownAt
const remaining = LOADING_INDICATOR_MIN_MS - visibleFor
if (remaining <= 0) {
showLoading.value = false
return
}
loadingIndicatorMinTimer = window.setTimeout(() => {
showLoading.value = false
loadingIndicatorMinTimer = null
}, remaining)
}
watch(
() => isReady.value,
(ready) => {
clearLoadingIndicatorDelayTimer()
if (ready) {
if (showLoading.value) {
clearLoadingIndicatorMinTimer()
hideLoadingIndicatorAfterMinimum()
}
return
}
clearLoadingIndicatorMinTimer()
if (showLoading.value || typeof window === 'undefined') {
return
}
loadingIndicatorDelayTimer = window.setTimeout(() => {
loadingIndicatorDelayTimer = null
if (isReady.value) {
return
}
showLoading.value = true
loadingIndicatorShownAt = Date.now()
}, LOADING_INDICATOR_DELAY_MS)
},
{ immediate: true },
)
onUnmounted(() => {
clearLoadingIndicatorDelayTimer()
clearLoadingIndicatorMinTimer()
})
return {
isPreviewVisible,
showLoading,
}
}

View File

@ -0,0 +1,321 @@
import { useGLTF } from '@tresjs/cientos'
import { useTexture } from '@tresjs/core'
import * as THREE from 'three'
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js'
import {
type ComputedRef,
markRaw,
onBeforeMount,
onUnmounted,
type Ref,
ref,
shallowRef,
watch,
} from 'vue'
import {
applyCapeTexture,
applyTexture,
applyThreeDSkinLayers,
createTransparentTexture,
loadTexture as loadSkinTexture,
} from '#ui/utils/webgl/skin-rendering.ts'
import type { SkinPreviewTuple } from './types'
const SKIN_LAYER_DEPTH_BIAS = -1
function configureSkinPreviewMesh(mesh: THREE.Mesh) {
const isSkinLayer = mesh.name.endsWith('_Layer')
mesh.renderOrder = 0
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((material) => {
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
material.transparent = isSkinLayer
material.alphaTest = 0.1
material.depthTest = true
material.depthWrite = true
material.polygonOffset = isSkinLayer
material.polygonOffsetFactor = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
material.polygonOffsetUnits = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
material.needsUpdate = true
})
}
function cloneSceneForRenderer(source: THREE.Object3D) {
const cloned = cloneSkeleton(source)
cloned.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.material) return
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((material) => material.clone())
: mesh.material.clone()
configureSkinPreviewMesh(mesh)
})
return markRaw(cloned)
}
function disposeSceneMaterials(root: THREE.Object3D | null) {
if (!root) return
const materials = new Set<THREE.Material>()
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.material) return
const meshMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
meshMaterials.forEach((material) => materials.add(material))
if (mesh.userData.threeDSkinLayersApplied) {
mesh.geometry.dispose()
}
})
materials.forEach((material) => material.dispose())
}
function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
root.updateWorldMatrix(true, true)
const result = new THREE.Box3()
const meshBox = new THREE.Box3()
let found = false
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
if (materials.length && materials.every((material) => material.visible === false)) return
if (!mesh.geometry.boundingBox) {
mesh.geometry.computeBoundingBox()
}
if (!mesh.geometry.boundingBox) return
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld)
result.union(meshBox)
found = true
})
return found && !result.isEmpty() ? result.clone() : null
}
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewScene({
selectedModelSrc,
textureSrc,
capeSrc,
initializeAnimations,
cleanupAnimationState,
}: {
selectedModelSrc: MaybeReadonlyRef<string>
textureSrc: MaybeReadonlyRef<string>
capeSrc: MaybeReadonlyRef<string | undefined>
initializeAnimations: (loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) => void
cleanupAnimationState: (root: THREE.Object3D | null) => void
}) {
const scene = shallowRef<THREE.Object3D | null>(null)
const lastCapeSrc = ref<string | undefined>(undefined)
const loadedModelSrc = ref<string | undefined>(undefined)
const loadedTextureSrc = ref<string | undefined>(undefined)
const loadedCapeSrc = ref<string | undefined>(undefined)
const texture = shallowRef<THREE.Texture | null>(null)
const capeTexture = shallowRef<THREE.Texture | null>(null)
const transparentTexture = createTransparentTexture()
const modelCenter = ref<SkinPreviewTuple>([0, 1, 0])
const modelSize = ref<SkinPreviewTuple>([1, 2, 1])
const isModelLoaded = ref(false)
const isTextureLoaded = ref(false)
let modelLoadVersion = 0
let textureLoadVersion = 0
let capeLoadVersion = 0
let isUnmounted = false
function applyTextureToLoadedModel() {
if (
!scene.value ||
!texture.value ||
loadedModelSrc.value !== selectedModelSrc.value ||
loadedTextureSrc.value !== textureSrc.value
) {
return
}
applyTexture(scene.value, texture.value)
applyThreeDSkinLayers(scene.value, texture.value)
}
function applyCapeTextureToLoadedModel() {
if (!scene.value || loadedModelSrc.value !== selectedModelSrc.value) return
applyCapeTexture(
scene.value,
loadedCapeSrc.value === capeSrc.value ? capeTexture.value : null,
transparentTexture,
)
}
async function loadModel(src: string) {
const loadVersion = ++modelLoadVersion
try {
isModelLoaded.value = false
const { scene: loadedScene, animations } = await useGLTF(src)
const clonedScene = cloneSceneForRenderer(loadedScene)
if (isUnmounted || loadVersion !== modelLoadVersion) {
disposeSceneMaterials(clonedScene)
return
}
const previousScene = scene.value
cleanupAnimationState(previousScene)
disposeSceneMaterials(previousScene)
scene.value = clonedScene
loadedModelSrc.value = src
applyTextureToLoadedModel()
applyCapeTextureToLoadedModel()
if (animations && animations.length > 0) {
initializeAnimations(clonedScene, animations)
}
updateModelInfo()
isModelLoaded.value = true
} catch (error) {
console.error('Failed to load model:', error)
if (!isUnmounted && loadVersion === modelLoadVersion) {
isModelLoaded.value = false
}
}
}
async function loadAndApplyTexture(src: string) {
if (!src) return null
try {
try {
return await loadSkinTexture(src)
} catch {
const tex = await useTexture([src])
tex.colorSpace = THREE.SRGBColorSpace
tex.flipY = false
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
return tex
}
} catch (error) {
console.error('Failed to load texture:', error)
return null
}
}
async function loadAndApplyCapeTexture(src: string | undefined) {
if (src === lastCapeSrc.value) return
const loadVersion = ++capeLoadVersion
lastCapeSrc.value = src
let loadedCapeTexture: THREE.Texture | null = null
if (src) {
loadedCapeTexture = await loadAndApplyTexture(src)
}
if (isUnmounted || loadVersion !== capeLoadVersion) return
capeTexture.value = loadedCapeTexture
loadedCapeSrc.value = src
applyCapeTextureToLoadedModel()
}
function updateModelInfo() {
const box = scene.value ? getVisibleMeshBox(scene.value) : null
if (!box) {
modelCenter.value = [0, 1, 0]
modelSize.value = [1, 2, 1]
return
}
const center = new THREE.Vector3()
const size = new THREE.Vector3()
box.getCenter(center)
box.getSize(size)
modelCenter.value = [center.x, center.y, center.z]
modelSize.value = [Math.max(size.x, 0.001), Math.max(size.y, 0.001), Math.max(size.z, 0.001)]
}
watch(
() => selectedModelSrc.value,
(src) => loadModel(src),
)
watch(
() => textureSrc.value,
async (newSrc) => {
const loadVersion = ++textureLoadVersion
isTextureLoaded.value = false
const loadedTexture = await loadAndApplyTexture(newSrc)
if (isUnmounted || loadVersion !== textureLoadVersion) return
texture.value = loadedTexture
loadedTextureSrc.value = newSrc
applyTextureToLoadedModel()
isTextureLoaded.value = true
},
)
watch(
() => capeSrc.value,
async (newCapeSrc) => {
await loadAndApplyCapeTexture(newCapeSrc)
},
)
onBeforeMount(async () => {
try {
isTextureLoaded.value = false
texture.value = await loadAndApplyTexture(textureSrc.value)
loadedTextureSrc.value = textureSrc.value
isTextureLoaded.value = true
await loadModel(selectedModelSrc.value)
if (capeSrc.value) {
await loadAndApplyCapeTexture(capeSrc.value)
}
} catch (error) {
console.error('Failed to initialize skin preview:', error)
}
})
onUnmounted(() => {
isUnmounted = true
modelLoadVersion++
textureLoadVersion++
capeLoadVersion++
cleanupAnimationState(scene.value)
disposeSceneMaterials(scene.value)
scene.value = null
transparentTexture.dispose()
})
return {
isModelLoaded,
isTextureLoaded,
modelCenter,
modelSize,
scene,
}
}

View File

@ -0,0 +1,84 @@
import type { Ref } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { useDebugLogger } from './debug-logger'
/**
* Observes when a target element becomes "stuck" (i.e. its natural position has scrolled out of view).
* Injects a zero-height sentinel element before the target and uses IntersectionObserver to detect
* when the sentinel leaves the viewport.
*/
export function useStickyObserver(target: Ref<HTMLElement | null | undefined>, label?: string) {
const debug = useDebugLogger(`sticky-observer${label ? `:${label}` : ''}`)
const isStuck = ref(false)
let sentinel: HTMLElement | null = null
let observer: IntersectionObserver | null = null
debug('init, target value:', target.value)
watch(
target,
(el, oldEl) => {
debug('watch fired, el:', el, 'oldEl:', oldEl)
observer?.disconnect()
sentinel?.remove()
observer = null
sentinel = null
if (el) {
debug(
'setting up sentinel, parent:',
el.parentElement,
'parentClasses:',
el.parentElement?.className,
)
debug('el classes:', el.className)
debug('el computed overflow:', getComputedStyle(el).overflow)
debug(
'parent computed overflow:',
el.parentElement ? getComputedStyle(el.parentElement).overflow : 'no parent',
)
sentinel = document.createElement('div')
sentinel.style.height = '0'
const parentGap = getComputedStyle(el.parentElement!).gap
sentinel.style.marginBottom = parentGap ? `-${parentGap}` : '0'
sentinel.setAttribute('aria-hidden', 'true')
el.parentElement?.insertBefore(sentinel, el)
debug('sentinel inserted, sentinel parent:', sentinel.parentElement?.className)
observer = new IntersectionObserver(
([entry]) => {
const wasStuck = isStuck.value
isStuck.value = !entry.isIntersecting
if (wasStuck !== isStuck.value) {
debug(
'isStuck changed:',
isStuck.value,
'intersectionRatio:',
entry.intersectionRatio,
'boundingClientRect:',
entry.boundingClientRect,
)
}
},
{ threshold: 0, rootMargin: '-1px 0px 0px 0px' },
)
observer.observe(sentinel)
debug('observer started')
} else {
debug('el is null, no observer set up')
}
},
{ flush: 'post' },
)
onUnmounted(() => {
debug('unmounted, cleaning up')
observer?.disconnect()
sentinel?.remove()
})
return { isStuck }
}

View File

@ -0,0 +1,282 @@
import type { FitAddon } from '@xterm/addon-fit'
import type { SearchAddon } from '@xterm/addon-search'
import type { ITerminalOptions, Terminal } from '@xterm/xterm'
import {
nextTick,
onBeforeUnmount,
onMounted,
type Ref,
ref,
type ShallowRef,
shallowRef,
} from 'vue'
export function getCssVar(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
function buildTerminalTheme() {
const surface2 = getCssVar('--surface-2', '#1d1f23')
const surface5 = getCssVar('--surface-5', '#42444a')
const textDefault = getCssVar('--color-text-default', '#b0bac5')
const textTertiary = getCssVar('--color-text-tertiary', '#96a2b0')
const textPrimary = getCssVar('--color-text-primary', '#ffffff')
const red = getCssVar('--color-red', '#ff496e')
const orange = getCssVar('--color-orange', '#ffa347')
const green = getCssVar('--color-green', '#1bd96a')
const blue = getCssVar('--color-blue', '#4a9eff')
const purple = getCssVar('--color-purple', '#bc3fbc')
return {
background: surface2,
foreground: textDefault,
cursor: textDefault,
cursorAccent: surface2,
selectionBackground: 'rgba(128, 128, 128, 0.3)',
black: surface2,
red,
green,
yellow: orange,
blue,
magenta: purple,
cyan: textTertiary,
white: textDefault,
brightBlack: surface5,
brightRed: red,
brightGreen: green,
brightYellow: orange,
brightBlue: blue,
brightMagenta: purple,
brightCyan: textTertiary,
brightWhite: textPrimary,
scrollbarSliderBackground: surface5,
scrollbarSliderHoverBackground: surface5,
scrollbarSliderActiveBackground: surface5,
overviewRulerBorder: 'transparent',
}
}
export interface UseTerminalOptions {
container: Ref<HTMLElement | null>
options?: ITerminalOptions
scrollback?: number
onReady?: (terminal: Terminal) => void
onResize?: () => void
}
export interface UseTerminalReturn {
terminal: ShallowRef<Terminal | null>
fitAddon: ShallowRef<FitAddon | null>
searchAddon: ShallowRef<SearchAddon | null>
isAtBottom: Ref<boolean>
write: (data: string) => void
writeln: (data: string) => void
clear: () => void
reset: () => void
fit: () => void
scrollToBottom: () => void
}
export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
const terminal = shallowRef<Terminal | null>(null)
const fitAddon = shallowRef<FitAddon | null>(null)
const searchAddon = shallowRef<SearchAddon | null>(null)
const isAtBottom = ref(true)
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
let wheelHandler: ((e: WheelEvent) => void) | null = null
let hasWritten = false
const pendingWrites: Array<{ data: string; newline: boolean }> = []
const write = (data: string) => {
if (terminal.value) {
terminal.value.write(data)
hasWritten = true
} else {
pendingWrites.push({ data, newline: false })
}
}
const writeln = (data: string) => {
if (terminal.value) {
if (hasWritten) {
terminal.value.write('\r\n' + data)
} else {
terminal.value.write(data)
hasWritten = true
}
} else {
pendingWrites.push({ data, newline: true })
}
}
const clear = () => {
terminal.value?.clear()
hasWritten = false
}
const reset = () => {
terminal.value?.reset()
hasWritten = false
}
const fit = () => {
const fa = fitAddon.value
const term = terminal.value
if (!fa || !term) return
const dims = fa.proposeDimensions()
if (dims) {
term.resize(dims.cols, dims.rows)
}
}
const scrollToBottom = () => {
terminal.value?.scrollToBottom()
isAtBottom.value = true
// dont even ask, shit is broken as hell
// scrollToBottom is unreliable so we have to spam it to make sure it actually goes to the bottom
let calls = 0
const interval = setInterval(() => {
terminal.value?.scrollToBottom()
if (++calls >= 10) clearInterval(interval)
}, 25)
}
const checkIfAtBottom = () => {
const term = terminal.value
if (!term) return
const buffer = term.buffer.active
isAtBottom.value = buffer.baseY - buffer.viewportY <= 2
}
onMounted(async () => {
const container = options.container.value
if (!container) return
const [{ Terminal }, { FitAddon }, { SearchAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-search'),
])
await import('@xterm/xterm/css/xterm.css')
const term = new Terminal({
disableStdin: true,
scrollback: options.scrollback ?? Infinity,
convertEol: true,
smoothScrollDuration: 125,
fontFamily: 'monospace',
fontSize: 14,
lineHeight: 1.5,
allowProposedApi: true,
theme: buildTerminalTheme(),
...options.options,
})
const fit = new FitAddon()
const search = new SearchAddon()
term.loadAddon(fit)
term.loadAddon(search)
term.open(container)
await nextTick()
const dims = fit.proposeDimensions()
if (dims) {
term.resize(dims.cols, dims.rows)
}
term.options.disableStdin = true
term.write('\x1b[?25l')
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true
const mod = e.ctrlKey || e.metaKey
if (!mod) return true
const key = e.key.toLowerCase()
if (key === 'a') {
e.preventDefault()
term.selectAll()
return false
}
if (key === 'c' || key === 'insert') {
return false
}
return true
})
wheelHandler = (e: WheelEvent) => {
e.preventDefault()
}
container.addEventListener('wheel', wheelHandler, { passive: false })
term.onScroll(() => checkIfAtBottom())
term.onWriteParsed(() => {
if (isAtBottom.value) {
term.scrollToBottom()
}
})
terminal.value = term
fitAddon.value = fit
searchAddon.value = search
for (const pending of pendingWrites) {
if (pending.newline) {
writeln(pending.data)
} else {
write(pending.data)
}
}
pendingWrites.length = 0
resizeObserver = new ResizeObserver(() => {
const d = fit.proposeDimensions()
if (d) {
term.resize(d.cols, d.rows)
}
options.onResize?.()
})
resizeObserver.observe(container)
themeObserver = new MutationObserver(() => {
term.options.theme = buildTerminalTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
})
options.onReady?.(term)
})
onBeforeUnmount(() => {
if (wheelHandler && options.container.value) {
options.container.value.removeEventListener('wheel', wheelHandler)
wheelHandler = null
}
resizeObserver?.disconnect()
resizeObserver = null
themeObserver?.disconnect()
themeObserver = null
terminal.value?.dispose()
terminal.value = null
})
return {
terminal,
fitAddon,
searchAddon,
isAtBottom,
write,
writeln,
clear,
reset,
fit,
scrollToBottom,
}
}

View File

@ -0,0 +1,67 @@
import type { ClassificationChoice, ClassificationResult } from './use-global-drop'
export type BatchDropPhase =
| 'idle'
| 'scanning'
| 'picking-instance'
| 'picking-world'
| 'confirming'
| 'installing'
| 'done'
| 'cancelled'
export type BatchDropScanState = 'pending' | 'scanning' | 'done' | 'skipped' | 'error'
export type BatchDropInstallState =
| 'queued'
| 'processing'
| 'success'
| 'failed'
| 'cancelled'
| 'skipped'
export type BatchDropResultStatus = 'success' | 'failed' | 'skipped' | 'cancelled'
export interface BatchDropItem {
id: string
sourcePath: string
name: string
/** Optional source qualifier used to disambiguate identical names (e.g. launcher folder). */
sourceLabel?: string
scanState: BatchDropScanState
classification?: ClassificationResult
/** Resolved type after confirmation: mod, resource_pack, shader_pack, world_save, litematic, schematic, datapack, modpack, instance. */
itemType?: string
innerBase?: string
launcherType?: string
basePath?: string
instanceFolder?: string
instancePath?: string
fromZip?: boolean
tempDir?: string
reason?: string
candidates?: string[]
choices?: ClassificationChoice[]
confirmedType?: string
selected?: boolean
importName?: string
installState?: BatchDropInstallState
installError?: string
symlink?: boolean
gameVersion?: string | null
loader?: string | null
loaderVersion?: string | null
gameDirOverride?: string | null
}
export interface BatchDropGroup {
id: string
/** Stable type key used by the UI to choose labels/options. */
type: string
items: BatchDropItem[]
}
export interface BatchDropResult {
id: string
name: string
status: BatchDropResultStatus
message?: string
}

View File

@ -0,0 +1,263 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import type { FileDropProvider, NativeFileDropEvent } from '#ui/providers/file-drop'
import { injectFileDrop } from '#ui/providers/file-drop'
import { injectLoadingState } from '#ui/providers/loading-state'
/**
* Classification result returned by the Rust backend.
*/
export interface ClassificationResult {
item_type:
| 'launcher'
| 'hmcl_launcher'
| 'mod'
| 'modpack'
| 'litematic'
| 'resource_pack'
| 'shader_pack'
| 'world_save'
| 'shortcut_resolved'
| 'multiple'
| 'unknown'
file_path?: string
candidates?: string[]
choices?: ClassificationChoice[]
launcher_type?: string
base_path?: string
/** For ZIP sources, the virtual folder inside the archive where launcher
* markers matched (e.g. `.minecraft`). Scan/import against
* `<extracted-temp>/<inner_base>`. */
innerBase?: string
launcher_dir?: string
data_dir?: string
original?: string
resolved_to?: ClassificationResult
reason?: string
}
export interface ClassificationChoice {
itemType: string
filePath?: string
innerBase?: string
candidates?: string[]
}
export interface UseGlobalDropOptions {
/**
* Function that classifies a dropped file by its filesystem path.
* Typically wraps `classifyDroppedItem` from `@/helpers/drop`.
*/
classifyFile: (path: string) => Promise<ClassificationResult>
/** Called when classification begins (before the loading bar). Receives the file name. */
onClassifyStart?: (fileName: string) => void
/**
* Called when a droppable item is successfully classified.
* @param type - 'launcher' | 'content'
* @param data - The (resolved) ClassificationResult
*/
onImportStart?: (type: string, data: ClassificationResult) => void
/** Called after processing ends (success or error). */
onImportEnd?: () => void
/**
* Called when the user drops multiple files. The consumer owns the rest of
* the batch flow and must call `finishBatch()` when it is done.
*/
onBatchStart?: (paths: string[]) => void
/** Called when an error occurs (unknown type, too many files, etc.). */
onError?: (message: string) => void
}
export function useGlobalDrop(
options: UseGlobalDropOptions,
fileDropOverride?: FileDropProvider | null,
) {
const fileDrop = fileDropOverride ?? injectFileDrop(null)
const loadingState = injectLoadingState(null)
const debug = useDebugLogger('useGlobalDrop')
const isDragging = ref(false)
const isProcessing = ref(false)
const droppedFileName = ref<string | null>(null)
let nativeFileDropUnlisten: (() => void) | null = null
let unmounted = false
/**
* Walk the `shortcut_resolved` chain up to 3 levels.
* If still a shortcut after depth 3, return the terminal node
* (the caller will treat it as unknown).
*/
function resolveClassification(result: ClassificationResult, depth = 0): ClassificationResult {
if (result.item_type === 'shortcut_resolved' && result.resolved_to && depth < 3) {
debug('resolveClassification: shortcut chain', {
depth,
from: result.file_path,
to: result.resolved_to.file_path,
})
return resolveClassification(result.resolved_to, depth + 1)
}
if (depth > 0)
debug('resolveClassification: resolved at depth', {
depth,
item_type: result.item_type,
file_path: result.file_path,
})
return result
}
async function handleNativeDrop(event: NativeFileDropEvent) {
if (event.type === 'enter' || event.type === 'over') {
isDragging.value = true
return
}
if (event.type === 'leave') {
isDragging.value = false
return
}
// ── type === 'drop' ──
isDragging.value = false
const { paths } = event
if (!paths?.length) {
debug('handleNativeDrop: drop event with empty paths — skipping')
return
}
// Multiple files start a batch flow owned by the consumer.
if (paths.length > 1) {
console.log('[BatchDrop] native drop MULTI paths=', paths)
if (options.onBatchStart) {
isProcessing.value = true
droppedFileName.value = null
options.onBatchStart(paths)
return
}
options.onError?.('multiple-files')
return
}
if (isProcessing.value) {
debug('handleNativeDrop: drop while another file is still processing — skipping')
return
}
isProcessing.value = true
droppedFileName.value = paths[0].split(/[/\\]/).pop() ?? 'file'
options.onClassifyStart?.(droppedFileName.value)
const loadToken = loadingState?.begin()
try {
const raw = await options.classifyFile(paths[0])
debug('classifyFile raw result', {
item_type: raw.item_type,
file_path: raw.file_path,
launcher_type: raw.launcher_type,
reason: raw.reason,
})
const resolved = resolveClassification(raw)
debug('resolveClassification final', {
item_type: resolved.item_type,
file_path: resolved.file_path,
launcher_type: resolved.launcher_type,
})
if (resolved.item_type === 'unknown') {
debug('routing: unknown type — passing to onImportStart so the consumer can decide', {
reason: resolved.reason,
})
options.onImportStart?.('unknown', resolved)
return
}
// If still a shortcut after max depth — treat as unknown
if (resolved.item_type === 'shortcut_resolved') {
debug('routing: shortcut exceeded max depth', { file_path: resolved.file_path })
options.onError?.('shortcut-exceeded')
return
}
if (resolved.item_type === 'launcher' || resolved.item_type === 'hmcl_launcher') {
debug('routing: launcher import', {
launcher_type: resolved.launcher_type,
base_path: resolved.base_path,
data_dir: resolved.data_dir,
})
options.onImportStart?.('launcher', resolved)
return
}
// Content types: mod, litematic, resource_pack, shader_pack, world_save
debug('routing: content import', { item_type: resolved.item_type })
options.onImportStart?.('content', resolved)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
options.onError?.(message)
} finally {
isProcessing.value = false
if (loadToken) loadingState?.end(loadToken)
options.onImportEnd?.()
}
}
async function setup() {
if (!fileDrop) {
debug('setup: fileDrop provider not available — native drops disabled')
return
}
let unlisten: () => void
try {
unlisten = await fileDrop.listenNativeFileDrop(handleNativeDrop)
debug('setup: native file drop listener registered successfully')
} catch (err) {
debug('setup: failed to register native file drop listener', err)
return
}
if (unmounted) {
debug('setup: component unmounted before listener was ready, cleaning up')
unlisten()
return
}
nativeFileDropUnlisten = unlisten
}
onMounted(() => {
void setup()
})
onUnmounted(() => {
unmounted = true
isDragging.value = false
isProcessing.value = false
if (nativeFileDropUnlisten) {
nativeFileDropUnlisten()
nativeFileDropUnlisten = null
}
})
/** Ends the batch flow and clears the global processing flag. */
function finishBatch() {
isProcessing.value = false
options.onImportEnd?.()
}
return {
isDragging,
isProcessing,
droppedFileName,
finishBatch,
}
}

View File

@ -0,0 +1,42 @@
import { computed, type ComputedRef } from 'vue'
import { useRoute } from 'vue-router'
export interface InstanceContext {
isInInstance: ComputedRef<boolean>
instanceId: ComputedRef<string | null>
currentPage: ComputedRef<string | null>
}
export function useInstanceContext(): InstanceContext {
const route = useRoute()
const instanceId = computed<string | null>(() => {
const param = route.params.id
if (param && typeof param === 'string') return param
const query = route.query.i
if (query && typeof query === 'string') return query
return null
})
const isInInstance = computed<boolean>(() => {
// /instance/:id and all subroutes (Mods, Files, Worlds, Screenshots, Logs)
if (route.path.startsWith('/instance/')) return true
// /browse/:type?i=:id
if (route.path.startsWith('/browse/') && route.query.i) return true
// /project/:id?i=:id
if (route.path.startsWith('/project/') && route.query.i) return true
return false
})
const currentPage = computed<string | null>(() => {
const segments = route.path.split('/').filter(Boolean)
return segments[0] ?? null
})
return { isInInstance, instanceId, currentPage }
}

View File

@ -0,0 +1,43 @@
import type { Ref } from 'vue'
import { onBeforeUnmount, watch } from 'vue'
import { injectLoadingState } from '#ui/providers/loading-state'
/**
* Register a `LoadingBar` token for as long as `pending` is truthy.
*
* Use this when the component that owns the load is not the natural place
* to mount a `<ReadyTransition>` (e.g. a page root with a complex v-if
* cascade where wrapping the template is awkward). `<ReadyTransition>`
* remains the preferred API when it fits.
*
* Safe to call without a provider mounted; becomes a no-op.
*/
export function useLoadingBarToken(pending: Ref<boolean>): void {
const loadingState = injectLoadingState(null)
if (!loadingState) return
let token: symbol | null = null
function release() {
if (token) {
loadingState.end(token)
token = null
}
}
watch(
pending,
(now) => {
if (typeof window === 'undefined') return
if (now && !token) {
token = loadingState.begin()
} else if (!now) {
release()
}
},
{ immediate: true },
)
onBeforeUnmount(release)
}

View File

@ -0,0 +1,61 @@
import { computed, ref, shallowRef } from 'vue'
import type { LoadingStateProvider } from '#ui/providers/loading-state'
export interface LoadingStateCoreOptions {
/** Initial value of the host kill-switch. Default: true. */
barEnabled?: boolean
}
/**
* Build a token-based `LoadingStateProvider` implementation.
*
* Multiple `ReadyTransition` instances (or any caller) can hold tokens at the
* same time; the bar stays visible while at least one is live. `end(token)`
* is idempotent so a stale token release after unmount is harmless.
*
* SSR safe: timers and DOM access are deferred to component code; this core
* is pure reactive state.
*/
export function createLoadingStateCore(opts: LoadingStateCoreOptions = {}): LoadingStateProvider {
const tokens = shallowRef<Set<symbol>>(new Set())
const barEnabled = ref(opts.barEnabled ?? true)
const pending = computed(() => tokens.value.size > 0)
function begin(): symbol {
const token = Symbol('loading-state-token')
const next = new Set(tokens.value)
next.add(token)
tokens.value = next
return token
}
function end(token: symbol): void {
if (!tokens.value.has(token)) return
const next = new Set(tokens.value)
next.delete(token)
tokens.value = next
}
function beginManual(durationMs = 500): void {
const token = begin()
if (typeof window === 'undefined') {
end(token)
return
}
window.setTimeout(() => end(token), durationMs)
}
function setEnabled(enabled: boolean): void {
barEnabled.value = enabled
}
return {
pending,
barEnabled,
begin,
end,
beginManual,
setEnabled,
}
}

View File

@ -0,0 +1,24 @@
import type { DefaultError, UseQueryReturnType } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { computed } from 'vue'
/** Subset of {@link UseQueryReturnType} passed to {@link useReadyState}. */
export type ReadyStateQuery<TData, TError = DefaultError> = Pick<
UseQueryReturnType<TData, TError>,
'isLoading' | 'data'
>
/**
* Returns true while a query is loading for the FIRST time (no cached data yet).
*
* Excludes background refetches and refetch-on-window-focus by design — those
* have `isLoading === false` once data exists in the cache, so `ReadyTransition`
* stays open and the loading bar stays silent.
*
* Pair with `<ReadyTransition :pending="var which is useReadyState(query)" />`.
*/
export function useReadyState<TData, TError = DefaultError>(
query: ReadyStateQuery<TData, TError>,
): Readonly<Ref<boolean>> {
return computed(() => query.isLoading.value && query.data.value === undefined)
}

View File

@ -0,0 +1,134 @@
import type { Archon } from '@modrinth/api-client'
import { useQuery } from '@tanstack/vue-query'
import { computed, type ComputedRef, ref } from 'vue'
import { injectModrinthClient } from '#ui/providers'
type UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
type UseServerImageOptions = {
enabled?: ComputedRef<boolean> | boolean
size?: number
includeProjectFallback?: boolean
}
export async function processImageBlob(blob: Blob, size: number): Promise<string> {
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const img = new Image()
img.onload = () => {
canvas.width = size
canvas.height = size
ctx.drawImage(img, 0, 0, size, size)
const dataURL = canvas.toDataURL('image/png')
URL.revokeObjectURL(img.src)
resolve(dataURL)
}
img.src = URL.createObjectURL(blob)
})
}
function getStatusCode(error: unknown): number | undefined {
const err = error as { statusCode?: number; response?: { status?: number } }
return err.statusCode ?? err.response?.status
}
function isNotFound(error: unknown): boolean {
return getStatusCode(error) === 404
}
export function useServerImage(
serverId: string,
upstream: UpstreamRef,
options: UseServerImageOptions = {},
) {
const client = injectModrinthClient()
const localImage = ref<string | null | undefined>(undefined)
const iconSize = options.size ?? 512
const includeProjectFallback = options.includeProjectFallback ?? false
const queryKey = computed(
() => ['servers', 'detail', serverId, 'icon', upstream.value?.project_id ?? null] as const,
)
const isEnabled = computed(() => {
const explicitEnabled =
typeof options.enabled === 'boolean' ? options.enabled : options.enabled?.value
return !!serverId && (explicitEnabled ?? true)
})
const { data: remoteImage, refetch } = useQuery({
queryKey,
queryFn: async (): Promise<string | null> => {
if (!serverId) return null
try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
try {
const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
return await processImageBlob(blob, iconSize)
} catch (error) {
if (!isNotFound(error)) throw error
}
try {
const blob = await client.kyros.files_v0.downloadFileWithAuth(
fsAuth,
'/server-icon-original.png',
)
return await processImageBlob(blob, iconSize)
} catch (error) {
if (!isNotFound(error)) throw error
}
} catch (error) {
console.debug('Server image fetch failed:', error)
return null
}
if (!includeProjectFallback || !upstream.value?.project_id) return null
try {
const project = await client.labrinth.projects_v2.get(upstream.value.project_id)
if (!project.icon_url) return null
const response = await fetch(project.icon_url)
if (!response.ok) return null
const blob = await response.blob()
return await processImageBlob(blob, iconSize)
} catch (error) {
console.debug('Project icon fallback failed:', error)
return null
}
},
enabled: isEnabled,
})
const image = computed(() => {
if (localImage.value === null) return undefined
const remote = remoteImage.value
if (remote === null) return undefined
return localImage.value ?? remote
})
function setImage(nextImage: string | null | undefined) {
localImage.value = nextImage
}
function clearImage() {
localImage.value = null
}
function resetLocalOverride() {
localImage.value = undefined
}
return {
image,
queryKey,
refetch,
setImage,
clearImage,
resetLocalOverride,
}
}

View File

@ -0,0 +1,214 @@
import type { Ref } from 'vue'
import { computed, ref, watch, watchEffect } from 'vue'
export interface ScrollViewportOptions {
onScroll?: () => void
onResize?: () => void
}
export interface VirtualScrollOptions {
itemHeight: number
bufferSize?: number
initialItemCount?: number
enabled?: Ref<boolean>
onNearEnd?: () => void
nearEndThreshold?: number
}
export function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
if (!element) return window
let current: HTMLElement | null = element.parentElement
while (current) {
const { overflowY } = getComputedStyle(current)
if (overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return window
}
export function getScrollTop(container: HTMLElement | Window): number {
return container instanceof Window ? window.scrollY : container.scrollTop
}
export function getViewportHeight(container: HTMLElement | Window): number {
return container instanceof Window ? window.innerHeight : container.clientHeight
}
export function useScrollViewport(options: ScrollViewportOptions = {}) {
const listContainer = ref<HTMLElement | null>(null)
const scrollContainer = ref<HTMLElement | Window | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const containerOffset = ref(0)
const relativeScrollTop = computed(() => Math.max(0, scrollTop.value - containerOffset.value))
function updateContainerOffset() {
const listEl = listContainer.value
const container = scrollContainer.value
if (!listEl || !container) return
if (container instanceof Window) {
containerOffset.value = listEl.getBoundingClientRect().top + window.scrollY
} else {
const listRect = listEl.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
containerOffset.value = listRect.top - containerRect.top + container.scrollTop
}
}
function syncScrollState() {
const listEl = listContainer.value
if (!listEl) return
const container = findScrollableAncestor(listEl)
scrollContainer.value = container
scrollTop.value = getScrollTop(container)
viewportHeight.value = getViewportHeight(container)
updateContainerOffset()
}
function resetScrollState() {
scrollTop.value = 0
viewportHeight.value = 0
containerOffset.value = 0
}
function handleScroll() {
if (scrollContainer.value) {
scrollTop.value = getScrollTop(scrollContainer.value)
updateContainerOffset()
}
options.onScroll?.()
}
function handleResize() {
syncScrollState()
options.onResize?.()
}
watchEffect((onCleanup) => {
if (typeof window === 'undefined') return
const listEl = listContainer.value
if (!listEl) return
const container = findScrollableAncestor(listEl)
scrollContainer.value = container
syncScrollState()
container.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
let resizeObserver: ResizeObserver | undefined
if (!(container instanceof Window)) {
resizeObserver = new ResizeObserver(() => {
syncScrollState()
})
resizeObserver.observe(container)
}
onCleanup(() => {
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
resizeObserver?.disconnect()
})
})
return {
resetScrollState,
containerOffset,
listContainer,
relativeScrollTop,
scrollContainer,
scrollTop,
syncScrollState,
updateContainerOffset,
viewportHeight,
}
}
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
const {
itemHeight,
bufferSize = 5,
initialItemCount = 20,
enabled,
onNearEnd,
nearEndThreshold = 0.2,
} = options
const {
listContainer,
relativeScrollTop,
resetScrollState,
scrollContainer,
syncScrollState,
viewportHeight,
} = useScrollViewport({
onScroll: checkNearEnd,
})
const totalHeight = computed(() => items.value.length * itemHeight)
const visibleRange = computed(() => {
if (enabled && !enabled.value) {
return { start: 0, end: items.value.length }
}
if (!listContainer.value || !scrollContainer.value) {
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
}
const start = Math.floor(relativeScrollTop.value / itemHeight)
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
const rangeSize = visibleCount + bufferSize * 2
const rangeStart = Math.min(
Math.max(0, start - bufferSize),
Math.max(0, items.value.length - rangeSize),
)
const rangeEnd = Math.min(items.value.length, rangeStart + rangeSize)
return {
start: rangeStart,
end: rangeEnd,
}
})
const visibleTop = computed(() =>
enabled && !enabled.value ? 0 : visibleRange.value.start * itemHeight,
)
const visibleItems = computed(() =>
items.value.slice(visibleRange.value.start, visibleRange.value.end),
)
function checkNearEnd() {
if (!onNearEnd || !listContainer.value || !viewportHeight.value) return
const containerBottom = listContainer.value.getBoundingClientRect().bottom
const remainingScroll = containerBottom - viewportHeight.value
if (remainingScroll < viewportHeight.value * nearEndThreshold) {
onNearEnd()
}
}
watch(items, () => {
syncScrollState()
})
return {
listContainer,
totalHeight,
visibleRange,
visibleTop,
visibleItems,
resetScrollState,
syncScrollState,
}
}